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
This commit is contained in:
Amr Bashir 2024-04-02 18:59:16 +02:00 committed by GitHub
parent dd07a36749
commit f8fde4f845
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 185 additions and 196 deletions

View File

@ -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());

View File

@ -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

View File

@ -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();
}
}
});
}

View File

@ -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,

View File

@ -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;
}
}
}