From cc21056ed3a48a62d034aaa0f252d46e8a66ef1e Mon Sep 17 00:00:00 2001 From: Josef Rokos Date: Mon, 12 Oct 2026 15:05:12 +0200 Subject: [PATCH] Moved config file out of the publicly served directory. Fixes #8 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + Dockerfile | 8 ++++++-- README.md | 16 ++++++++++++++++ src/config.rs | 47 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 13 ++++++++++++- 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2c4421..fe66cb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ Unit tests are `#[cfg(test)] mod tests` at the bottom of the file they test (`#[ ## Running locally - `config.toml` (gitignored) is read from the working directory, or from `-c `. Copy `config_sample.toml`. The bind address comes from `[network]` there, not from `LEPTOS_SITE_ADDR`. +- The config file holds secrets and must stay outside the site root (`target/site`), which is served as static files; the server refuses to start otherwise (`is_publicly_served` in `src/config.rs`). In Docker it is mounted to `/app/config/config.toml`. - Migrations in `migrations/` are embedded by `sqlx::migrate!()` and run on startup. - First start seeds an `admin` / `admin` user, a placeholder company, empty mail templates and an appearance row (`create_admin`, `check_company`, `check_messages`, `check_appearance` in `src/main.rs`). - With `transport = "File"` e-mails are written as `.eml` into `Maildir/` instead of being sent. diff --git a/Dockerfile b/Dockerfile index 665f95b..1afe66f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,10 @@ COPY --from=builder /app/target/site /app/target/site # Set any required env variables and EXPOSE 3000 -# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml -- +# The config file holds passwords and the session key. Mount it to /app/config, never under +# /app/target/site, which is served as static files. +RUN mkdir -p /app/config +VOLUME /app/config + # Run the server -CMD ["/app/rezervator", "-c /app/target/site/data/config.toml"] \ No newline at end of file +CMD ["/app/rezervator", "-c", "/app/config/config.toml"] \ No newline at end of file diff --git a/README.md b/README.md index 3ba3489..9710b6f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,22 @@ The `wasm-bindgen` command line tool must be exactly the same version as the `wa `cargo leptos watch` +## Configuration + +The server reads `config.toml` from the working directory, or from the path given with `-c`. Use `config_sample.toml` as a template. + +The file contains the database and SMTP passwords and the session key. Keep it outside the site directory (`target/site`): everything in there is served as static files. The server refuses to start when the config file is inside it. + +## Running in Docker + +Mount the config file to `/app/config/config.toml`: + +`docker run -p 3000:3000 -v /path/to/config-dir:/app/config rezervator` + +Set `bind_ip = '0.0.0.0'` in the config so the server is reachable from outside the container. + +Older versions of the image expected the config in `/app/target/site/data/config.toml`, where it could be downloaded from `/data/config.toml`. If you ran such a container, move the file and change the database password, the SMTP password and the session key. + ## Running tests `cargo test --features ssr` diff --git a/src/config.rs b/src/config.rs index 1b26a53..9e78c40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,8 @@ use std::fs::read_to_string; #[cfg(feature = "ssr")] use std::net::SocketAddr; #[cfg(feature = "ssr")] +use std::path::Path; +#[cfg(feature = "ssr")] use std::str::FromStr; #[cfg(feature = "ssr")] use serde::Deserialize; @@ -149,6 +151,16 @@ impl Configuration { } } +/// The config file holds passwords and the session key, so it must never lie in the directory +/// that is served as static files. Returns true when `config_path` is inside `site_root`. +#[cfg(feature = "ssr")] +pub fn is_publicly_served(config_path: &Path, site_root: &Path) -> bool { + match (config_path.canonicalize(), site_root.canonicalize()) { + (Ok(config), Ok(root)) => config.starts_with(root), + _ => false + } +} + #[cfg(feature = "ssr")] pub fn load_config(path: &str) -> Configuration { toml::from_str(read_to_string(path).expect("Can not open config file").as_str()).expect("Can not parse config file") @@ -177,6 +189,41 @@ mod tests { assert_eq!(config.mailing().server(), &None); } + /// Creates `//site/data` and returns the `/` directory + fn site_dir(name: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!("rezervator-test-{}-{}", name, std::process::id())); + std::fs::create_dir_all(base.join("site/data")).unwrap(); + base + } + + #[test] + fn config_inside_site_root_is_publicly_served() { + let base = site_dir("inside"); + let config = base.join("site/data/config.toml"); + std::fs::write(&config, "").unwrap(); + + assert!(is_publicly_served(&config, &base.join("site"))); + // Relative segments must not hide it + assert!(is_publicly_served(&base.join("site/data/../data/config.toml"), &base.join("site"))); + + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn config_outside_site_root_is_not_publicly_served() { + let base = site_dir("outside"); + let config = base.join("config.toml"); + std::fs::write(&config, "").unwrap(); + + assert!(!is_publicly_served(&config, &base.join("site"))); + // A sibling directory with the same prefix is not inside the site root + std::fs::create_dir_all(base.join("site-config")).unwrap(); + std::fs::write(base.join("site-config/config.toml"), "").unwrap(); + assert!(!is_publicly_served(&base.join("site-config/config.toml"), &base.join("site"))); + + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn sample_session_key_is_long_enough() { // actix_web::cookie::Key::from panics on keys shorter than 64 bytes diff --git a/src/main.rs b/src/main.rs index 73f6e09..279492c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,8 @@ async fn main() -> std::io::Result<()> { use sqlx::migrate; use sqlx::postgres::PgPoolOptions; use rezervator::backend::auth_middleware::Authentication; - use rezervator::config::load_config; + use rezervator::config::{is_publicly_served, load_config}; + use std::path::Path; use env_logger::Env; use std::env::args; use getopts::Options; @@ -54,6 +55,16 @@ async fn main() -> std::io::Result<()> { info!("Starting server"); let conf = get_configuration(None).unwrap(); + + // The site root is served as static files, the config with passwords must not be there + if is_publicly_served(Path::new(cfg_path.trim()), Path::new(&*conf.leptos_options.site_root)) { + let msg = format!( + "Config file {} is inside the publicly served directory {}. Move it elsewhere and change the passwords and the session key in it.", + cfg_path.trim(), conf.leptos_options.site_root); + error!("{}", msg); + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg)); + } + let key = Key::from(srv_conf.session().key()); let pool = PgPoolOptions::new()