From f8fde4f845e66d46e29406c997181990311579e5 Mon Sep 17 00:00:00 2001 From: Amr Bashir Date: Tue, 2 Apr 2024 18:59:16 +0200 Subject: [PATCH] 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 --- tooling/cli/src/dev.rs | 5 +- .../cli/src/{helpers => dev}/auto-reload.js | 6 +- tooling/cli/src/dev/builtin_dev_server.rs | 179 +++++++++++++++++ tooling/cli/src/helpers/mod.rs | 1 - tooling/cli/src/helpers/web_dev_server.rs | 190 ------------------ 5 files changed, 185 insertions(+), 196 deletions(-) rename tooling/cli/src/{helpers => dev}/auto-reload.js (85%) create mode 100644 tooling/cli/src/dev/builtin_dev_server.rs delete mode 100644 tooling/cli/src/helpers/web_dev_server.rs diff --git a/tooling/cli/src/dev.rs b/tooling/cli/src/dev.rs index 0f7edd0ce..d4eac2ec9 100644 --- a/tooling/cli/src/dev.rs +++ b/tooling/cli/src/dev.rs @@ -29,6 +29,8 @@ use std::{ }, }; +mod builtin_dev_server; + static BEFORE_DEV: OnceLock>> = OnceLock::new(); static KILL_BEFORE_DEV_FLAG: OnceLock = 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()); diff --git a/tooling/cli/src/helpers/auto-reload.js b/tooling/cli/src/dev/auto-reload.js similarity index 85% rename from tooling/cli/src/helpers/auto-reload.js rename to tooling/cli/src/dev/auto-reload.js index 4c01b3a92..ab18a85aa 100644 --- a/tooling/cli/src/helpers/auto-reload.js +++ b/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 diff --git a/tooling/cli/src/dev/builtin_dev_server.rs b/tooling/cli/src/dev/builtin_dev_server.rs new file mode 100644 index 000000000..1353cb440 --- /dev/null +++ b/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>(dir: P, ip: IpAddr, port: Option) -> crate::Result { + 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) -> 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) -> 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, address: &SocketAddr) -> Vec { + fn with_html_head(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> { + 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(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(); + } + } + }); +} diff --git a/tooling/cli/src/helpers/mod.rs b/tooling/cli/src/helpers/mod.rs index 5256b3802..4d80f1d66 100644 --- a/tooling/cli/src/helpers/mod.rs +++ b/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, diff --git a/tooling/cli/src/helpers/web_dev_server.rs b/tooling/cli/src/helpers/web_dev_server.rs deleted file mode 100644 index 4e14f0da5..000000000 --- a/tooling/cli/src/helpers/web_dev_server.rs +++ /dev/null @@ -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>(path: P, ip: IpAddr, port: Option) -> crate::Result { - 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) -> 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(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) { - 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; - } - } -}