Просмотр исходного кода

fix(cli): disable directory traversal in builtin dev server (#9344)

* fix(cli): disable directory traversal in builtin dev server

This PR also includes a cleanup refactor of the server

* Update builtin_dev_server.rs
Amr Bashir 1 год назад
Родитель
Сommit
f8fde4f845

+ 3 - 2
tooling/cli/src/dev.rs

@@ -29,6 +29,8 @@ use std::{
   },
 };
 
+mod builtin_dev_server;
+
 static BEFORE_DEV: OnceLock<Mutex<Arc<SharedChild>>> = OnceLock::new();
 static KILL_BEFORE_DEV_FLAG: OnceLock<AtomicBool> = OnceLock::new();
 
@@ -327,7 +329,6 @@ pub fn setup(
     .clone();
   if !options.no_dev_server && dev_url.is_none() {
     if let Some(FrontendDist::Directory(path)) = &frontend_dist {
-      use crate::helpers::web_dev_server;
       if path.exists() {
         let path = path.canonicalize()?;
         let ip = if mobile {
@@ -335,7 +336,7 @@ pub fn setup(
         } else {
           Ipv4Addr::new(127, 0, 0, 1).into()
         };
-        let server_url = web_dev_server::start(path, ip, options.port)?;
+        let server_url = builtin_dev_server::start(path, ip, options.port)?;
         let server_url = format!("http://{server_url}");
         dev_url = Some(server_url.parse().unwrap());
 

+ 3 - 3
tooling/cli/src/helpers/auto-reload.js → tooling/cli/src/dev/auto-reload.js

@@ -5,14 +5,14 @@
 // taken from https://github.com/thedodd/trunk/blob/5c799dc35f1f1d8f8d3d30c8723cbb761a9b6a08/src/autoreload.js
 
 ;(function () {
-  const url = '{{reload_url}}'
+  const reload_url = '{{reload_url}}'
+  const url = reload_url ? reload_url : window.location.href
   const poll_interval = 5000
   const reload_upon_connect = () => {
     window.setTimeout(() => {
       // when we successfully reconnect, we'll force a
       // reload (since we presumably lost connection to
-      // trunk due to it being killed, so it will have
-      // rebuilt on restart)
+      // tauri-cli due to it being killed)
       const ws = new WebSocket(url)
       ws.onopen = () => window.location.reload()
       ws.onclose = reload_upon_connect

+ 179 - 0
tooling/cli/src/dev/builtin_dev_server.rs

@@ -0,0 +1,179 @@
+// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-License-Identifier: MIT
+
+use axum::{
+  extract::{ws, State, WebSocketUpgrade},
+  http::{header, StatusCode, Uri},
+  response::{IntoResponse, Response},
+};
+use html5ever::{namespace_url, ns, LocalName, QualName};
+use kuchiki::{traits::TendrilSink, NodeRef};
+use std::{
+  net::{IpAddr, SocketAddr},
+  path::{Path, PathBuf},
+  thread,
+  time::Duration,
+};
+use tauri_utils::mime_type::MimeType;
+use tokio::sync::broadcast::{channel, Sender};
+
+const RELOAD_SCRIPT: &str = include_str!("./auto-reload.js");
+
+#[derive(Clone)]
+struct ServerState {
+  dir: PathBuf,
+  address: SocketAddr,
+  tx: Sender<()>,
+}
+
+pub fn start<P: AsRef<Path>>(dir: P, ip: IpAddr, port: Option<u16>) -> crate::Result<SocketAddr> {
+  let dir = dir.as_ref();
+  let dir = dunce::canonicalize(dir)?;
+
+  // bind port and tcp listener
+  let auto_port = port.is_none();
+  let mut port = port.unwrap_or(1430);
+  let (tcp_listener, address) = loop {
+    let address = SocketAddr::new(ip, port);
+    if let Ok(tcp) = std::net::TcpListener::bind(address) {
+      tcp.set_nonblocking(true)?;
+      break (tcp, address);
+    }
+
+    if !auto_port {
+      anyhow::bail!("Couldn't bind to {port} on {ip}");
+    }
+
+    port += 1;
+  };
+
+  let (tx, _) = channel(1);
+
+  // watch dir for changes
+  let tx_c = tx.clone();
+  watch(dir.clone(), move || {
+    let _ = tx_c.send(());
+  });
+
+  let state = ServerState { dir, tx, address };
+
+  // start router thread
+  std::thread::spawn(move || {
+    tokio::runtime::Builder::new_current_thread()
+      .enable_io()
+      .build()
+      .expect("failed to start tokio runtime for builtin dev server")
+      .block_on(async move {
+        let router = axum::Router::new()
+          .fallback(handler)
+          .route("/__tauri_cli", axum::routing::get(ws_handler))
+          .with_state(state);
+
+        axum::serve(tokio::net::TcpListener::from_std(tcp_listener)?, router).await
+      })
+      .expect("builtin server errored");
+  });
+
+  Ok(address)
+}
+
+async fn handler(uri: Uri, state: State<ServerState>) -> impl IntoResponse {
+  // Frontend files should not contain query parameters. This seems to be how vite handles it.
+  let uri = uri.path();
+
+  let uri = if uri == "/" {
+    uri
+  } else {
+    uri.strip_prefix('/').unwrap_or(uri)
+  };
+
+  let bytes = fs_read_scoped(state.dir.join(uri), &state.dir)
+    .or_else(|_| fs_read_scoped(state.dir.join(format!("{}.html", &uri)), &state.dir))
+    .or_else(|_| fs_read_scoped(state.dir.join(format!("{}/index.html", &uri)), &state.dir))
+    .or_else(|_| std::fs::read(state.dir.join("index.html")));
+
+  match bytes {
+    Ok(mut bytes) => {
+      let mime_type = MimeType::parse_with_fallback(&bytes, uri, MimeType::OctetStream);
+      if mime_type == MimeType::Html.to_string() {
+        bytes = inject_address(bytes, &state.address);
+      }
+      (StatusCode::OK, [(header::CONTENT_TYPE, mime_type)], bytes)
+    }
+    Err(_) => (
+      StatusCode::NOT_FOUND,
+      [(header::CONTENT_TYPE, "text/plain".into())],
+      vec![],
+    ),
+  }
+}
+
+async fn ws_handler(ws: WebSocketUpgrade, state: State<ServerState>) -> Response {
+  ws.on_upgrade(move |mut ws| async move {
+    let mut rx = state.tx.subscribe();
+    while tokio::select! {
+        _ = ws.recv() => return,
+        fs_reload_event = rx.recv() => fs_reload_event.is_ok(),
+    } {
+      let msg = ws::Message::Text(r#"{"reload": true}"#.to_owned());
+      if ws.send(msg).await.is_err() {
+        break;
+      }
+    }
+  })
+}
+
+fn inject_address(html_bytes: Vec<u8>, address: &SocketAddr) -> Vec<u8> {
+  fn with_html_head<F: FnOnce(&NodeRef)>(document: &mut NodeRef, f: F) {
+    if let Ok(ref node) = document.select_first("head") {
+      f(node.as_node())
+    } else {
+      let node = NodeRef::new_element(
+        QualName::new(None, ns!(html), LocalName::from("head")),
+        None,
+      );
+      f(&node);
+      document.prepend(node)
+    }
+  }
+
+  let mut document = kuchiki::parse_html().one(String::from_utf8_lossy(&html_bytes).into_owned());
+  with_html_head(&mut document, |head| {
+    let script = RELOAD_SCRIPT.replace("{{reload_url}}", &format!("ws://{address}/__tauri_cli"));
+    let script_el = NodeRef::new_element(QualName::new(None, ns!(html), "script".into()), None);
+    script_el.append(NodeRef::new_text(script));
+    head.prepend(script_el);
+  });
+
+  tauri_utils::html::serialize_node(&document)
+}
+
+fn fs_read_scoped(path: PathBuf, scope: &Path) -> crate::Result<Vec<u8>> {
+  let path = dunce::canonicalize(path)?;
+  if path.starts_with(scope) {
+    std::fs::read(path).map_err(Into::into)
+  } else {
+    anyhow::bail!("forbidden path")
+  }
+}
+
+fn watch<F: Fn() + Send + 'static>(dir: PathBuf, handler: F) {
+  thread::spawn(move || {
+    let (tx, rx) = std::sync::mpsc::channel();
+
+    let mut watcher = notify_debouncer_mini::new_debouncer(Duration::from_secs(1), tx)
+      .expect("failed to start builtin server fs watcher");
+
+    watcher
+      .watcher()
+      .watch(&dir, notify::RecursiveMode::Recursive)
+      .expect("builtin server failed to watch dir");
+
+    loop {
+      if rx.recv().is_ok() {
+        handler();
+      }
+    }
+  });
+}

+ 0 - 1
tooling/cli/src/helpers/mod.rs

@@ -11,7 +11,6 @@ pub mod npm;
 pub mod prompts;
 pub mod template;
 pub mod updater_signature;
-pub mod web_dev_server;
 
 use std::{
   collections::HashMap,

+ 0 - 190
tooling/cli/src/helpers/web_dev_server.rs

@@ -1,190 +0,0 @@
-// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
-// SPDX-License-Identifier: Apache-2.0
-// SPDX-License-Identifier: MIT
-
-use axum::{
-  extract::{ws::WebSocket, WebSocketUpgrade},
-  http::{header::CONTENT_TYPE, StatusCode, Uri},
-  response::IntoResponse,
-  routing::get,
-  serve, Router,
-};
-use html5ever::{namespace_url, ns, LocalName, QualName};
-use kuchiki::{traits::TendrilSink, NodeRef};
-use notify::RecursiveMode;
-use notify_debouncer_mini::new_debouncer;
-use std::{
-  net::{IpAddr, SocketAddr},
-  path::{Path, PathBuf},
-  sync::{mpsc::sync_channel, Arc},
-  thread,
-  time::Duration,
-};
-use tauri_utils::mime_type::MimeType;
-use tokio::sync::broadcast::{channel, Sender};
-
-const AUTO_RELOAD_SCRIPT: &str = include_str!("./auto-reload.js");
-
-struct State {
-  serve_dir: PathBuf,
-  address: SocketAddr,
-  tx: Sender<()>,
-}
-
-pub fn start<P: AsRef<Path>>(path: P, ip: IpAddr, port: Option<u16>) -> crate::Result<SocketAddr> {
-  let serve_dir = path.as_ref().to_path_buf();
-
-  let (server_url_tx, server_url_rx) = std::sync::mpsc::channel();
-
-  std::thread::spawn(move || {
-    tokio::runtime::Builder::new_current_thread()
-      .enable_io()
-      .build()
-      .unwrap()
-      .block_on(async move {
-        let (tx, _) = channel(1);
-
-        let tokio_tx = tx.clone();
-        let serve_dir_ = serve_dir.clone();
-        thread::spawn(move || {
-          let (tx, rx) = sync_channel(1);
-          let mut watcher = new_debouncer(Duration::from_secs(1), move |r| {
-            if let Ok(events) = r {
-              tx.send(events).unwrap()
-            }
-          })
-          .unwrap();
-
-          watcher
-            .watcher()
-            .watch(&serve_dir_, RecursiveMode::Recursive)
-            .unwrap();
-
-          loop {
-            if rx.recv().is_ok() {
-              let _ = tokio_tx.send(());
-            }
-          }
-        });
-
-        let mut auto_port = false;
-        let mut port = port.unwrap_or_else(|| {
-          auto_port = true;
-          1430
-        });
-
-        let (listener, server_url) = loop {
-          let server_url = SocketAddr::new(ip, port);
-          if let Ok(listener) = tokio::net::TcpListener::bind(server_url).await {
-            break (Some(listener), server_url);
-          }
-
-          if !auto_port {
-            break (None, server_url);
-          }
-
-          port += 1;
-        };
-
-        if let Some(listener) = listener {
-          let state = Arc::new(State {
-            serve_dir,
-            tx,
-            address: server_url,
-          });
-          let state_ = state.clone();
-          let router = Router::new()
-            .fallback(move |uri| handler(uri, state_))
-            .route(
-              "/__tauri_cli",
-              get(move |ws: WebSocketUpgrade| async move {
-                ws.on_upgrade(|socket| async move { ws_handler(socket, state).await })
-              }),
-            );
-
-          server_url_tx.send(Ok(server_url)).unwrap();
-          serve(listener, router).await.unwrap();
-        } else {
-          server_url_tx
-            .send(Err(anyhow::anyhow!(
-              "failed to start development server on {server_url}"
-            )))
-            .unwrap();
-        }
-      })
-  });
-
-  server_url_rx.recv().unwrap()
-}
-
-async fn handler(uri: Uri, state: Arc<State>) -> impl IntoResponse {
-  // Frontend files should not contain query parameters. This seems to be how vite handles it.
-  let uri = uri.path();
-
-  let uri = if uri == "/" {
-    uri
-  } else {
-    uri.strip_prefix('/').unwrap_or(uri)
-  };
-
-  let file = std::fs::read(state.serve_dir.join(uri))
-    .or_else(|_| std::fs::read(state.serve_dir.join(format!("{}.html", &uri))))
-    .or_else(|_| std::fs::read(state.serve_dir.join(format!("{}/index.html", &uri))))
-    .or_else(|_| std::fs::read(state.serve_dir.join("index.html")));
-
-  file
-    .map(|mut f| {
-      let mime_type = MimeType::parse_with_fallback(&f, uri, MimeType::OctetStream);
-      if mime_type == MimeType::Html.to_string() {
-        let mut document = kuchiki::parse_html().one(String::from_utf8_lossy(&f).into_owned());
-        fn with_html_head<F: FnOnce(&NodeRef)>(document: &mut NodeRef, f: F) {
-          if let Ok(ref node) = document.select_first("head") {
-            f(node.as_node())
-          } else {
-            let node = NodeRef::new_element(
-              QualName::new(None, ns!(html), LocalName::from("head")),
-              None,
-            );
-            f(&node);
-            document.prepend(node)
-          }
-        }
-
-        with_html_head(&mut document, |head| {
-          let script_el =
-            NodeRef::new_element(QualName::new(None, ns!(html), "script".into()), None);
-          script_el.append(NodeRef::new_text(AUTO_RELOAD_SCRIPT.replace(
-            "{{reload_url}}",
-            &format!("ws://{}/__tauri_cli", state.address),
-          )));
-          head.prepend(script_el);
-        });
-
-        f = tauri_utils::html::serialize_node(&document);
-      }
-
-      (StatusCode::OK, [(CONTENT_TYPE, mime_type)], f)
-    })
-    .unwrap_or_else(|_| {
-      (
-        StatusCode::NOT_FOUND,
-        [(CONTENT_TYPE, "text/plain".into())],
-        vec![],
-      )
-    })
-}
-
-async fn ws_handler(mut ws: WebSocket, state: Arc<State>) {
-  let mut rx = state.tx.subscribe();
-  while tokio::select! {
-      _ = ws.recv() => return,
-      fs_reload_event = rx.recv() => fs_reload_event.is_ok(),
-  } {
-    let ws_send = ws.send(axum::extract::ws::Message::Text(
-      r#"{"reload": true}"#.to_owned(),
-    ));
-    if ws_send.await.is_err() {
-      break;
-    }
-  }
-}