feat(console): embed the management UI in release binaries
- build locked Web dependencies automatically for release profiles - keep debug Cargo builds independent from the Vite development server - embed generated Host, Page, manifest, and service worker assets - serve exact control-plane assets with MIME and cache headers - reject non-canonical and unknown paths without an SPA fallback - cover embedded document boundaries and path validation
This commit is contained in:
@@ -99,6 +99,7 @@ pub fn app(console: Arc<Console>, allowed_origins: Vec<HeaderValue>) -> Router {
|
||||
.on_response(DefaultOnResponse::new().level(Level::INFO)),
|
||||
)
|
||||
.with_state(console)
|
||||
.merge(web::router())
|
||||
}
|
||||
|
||||
/// Builds the public data-plane API around the same resident Runtime.
|
||||
|
||||
@@ -36,6 +36,7 @@ mod api;
|
||||
mod invocation_persistence;
|
||||
mod kv_backend;
|
||||
mod persistence;
|
||||
mod web;
|
||||
mod wit_registry;
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Embedded Solid management UI served by the control-plane listener.
|
||||
//!
|
||||
//! The router is empty in normal Debug builds because Vite owns Web assets
|
||||
//! during development. Release builds embed Cargo `OUT_DIR/web`; requests only
|
||||
//! resolve exact files, so an unknown URL cannot accidentally receive the Host
|
||||
//! document as an SPA fallback.
|
||||
|
||||
use axum::Router;
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::Path,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
static WEB_ASSETS: Dir<'static> = include_dir!("$OUT_DIR/web");
|
||||
|
||||
pub(crate) fn router() -> Router {
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
{
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/{*path}", get(asset))
|
||||
}
|
||||
|
||||
#[cfg(not(wasmeld_embedded_web))]
|
||||
{
|
||||
Router::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
async fn index() -> Response {
|
||||
response("index.html")
|
||||
}
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
async fn asset(Path(path): Path<String>) -> Response {
|
||||
response(&path)
|
||||
}
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
fn response(path: &str) -> Response {
|
||||
// The wildcard route is public input. Reject non-canonical paths before
|
||||
// looking inside the compile-time directory, even though `include_dir`
|
||||
// itself cannot access the host filesystem at runtime.
|
||||
if !valid_asset_path(path) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
let Some(file) = WEB_ASSETS.get_file(path) else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let content_type = mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str()
|
||||
.to_owned();
|
||||
let cache_control = if path.starts_with("assets/") {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
let mut builder = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, cache_control)
|
||||
.header("x-content-type-options", "nosniff");
|
||||
if path == "sw.js" {
|
||||
builder = builder.header("service-worker-allowed", "/");
|
||||
}
|
||||
builder
|
||||
.body(Body::from(Bytes::from_static(file.contents())))
|
||||
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||
}
|
||||
|
||||
#[cfg(wasmeld_embedded_web)]
|
||||
fn valid_asset_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& !path.starts_with('/')
|
||||
&& !path.contains('\\')
|
||||
&& path
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | ".."))
|
||||
}
|
||||
|
||||
#[cfg(all(test, wasmeld_embedded_web))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embeds_both_document_boundaries_and_pwa_assets() {
|
||||
for path in ["index.html", "page.html", "manifest.webmanifest", "sw.js"] {
|
||||
assert!(WEB_ASSETS.get_file(path).is_some(), "missing {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_canonical_asset_paths() {
|
||||
assert!(!valid_asset_path("../index.html"));
|
||||
assert!(!valid_asset_path("assets//app.js"));
|
||||
assert!(!valid_asset_path(r"assets\app.js"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user