use crate::backend::DatabaseBackend;
use crate::settings::{Environment, Settings};
const INSECURE_DEV_SECRET_KEY: &str = "umbral-insecure-dev-key-change-me";
const DEFAULT_ALLOWED_HOSTS: &[&str] = &["localhost", "127.0.0.1"];
pub struct SystemCheck {
pub id: &'static str,
pub run: fn(&CheckContext<'_>) -> Vec<SystemCheckFinding>,
}
pub struct CheckContext<'a> {
pub backend: &'a dyn DatabaseBackend,
pub settings: &'a Settings,
pub provides_storage: bool,
pub registered_plugin_names: &'a [&'a str],
}
#[derive(Debug)]
pub struct SystemCheckFinding {
pub check_id: &'static str,
pub severity: Severity,
pub location: CheckLocation,
pub message: String,
pub hint: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub enum CheckLocation {
Field {
plugin: &'static str,
model: &'static str,
field: &'static str,
},
Model {
plugin: &'static str,
model: &'static str,
},
Plugin { plugin: &'static str },
Route { path: String },
Settings,
}
pub fn framework_checks() -> Vec<SystemCheck> {
vec![
SystemCheck {
id: "settings.required",
run: settings_required,
},
SystemCheck {
id: "settings.allowed_hosts",
run: settings_allowed_hosts,
},
SystemCheck {
id: "settings.host_validation",
run: settings_host_validation,
},
SystemCheck {
id: "settings.log_level",
run: settings_log_level,
},
SystemCheck {
id: "backend.url_scheme.matches_active_backend",
run: backend_url_scheme_matches_active_backend,
},
SystemCheck {
id: "field.backend",
run: field_backend,
},
SystemCheck {
id: "field.storage_backend",
run: field_storage_backend,
},
SystemCheck {
id: "field.choices_default",
run: field_choices_default,
},
SystemCheck {
id: "plugin.security_missing",
run: plugin_security_missing,
},
]
}
fn settings_required(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
let insecure = ctx.settings.secret_key == INSECURE_DEV_SECRET_KEY;
if matches!(ctx.settings.environment, Environment::Prod) && insecure {
findings.push(SystemCheckFinding {
check_id: "settings.required",
severity: Severity::Error,
location: CheckLocation::Settings,
message: "Settings.secret_key is still set to the insecure dev default in Environment::Prod. This is a hard production risk.".to_string(),
hint: Some("set UMBRAL_SECRET_KEY in your production env, or change `secret_key` in umbral.toml.".to_string()),
});
return findings;
}
if insecure && !is_loopback_bind(&ctx.settings.bind_addr) {
findings.push(SystemCheckFinding {
check_id: "settings.required",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"Settings.secret_key is the insecure dev default, but bind_addr `{}` doesn't look like loopback. Set UMBRAL_ENVIRONMENT=Prod if this is a production deployment so the boot-check fails loudly instead of just warning.",
ctx.settings.bind_addr,
),
hint: Some("set UMBRAL_SECRET_KEY, or restrict bind_addr to 127.0.0.1 for local dev.".to_string()),
});
}
findings
}
fn settings_host_validation(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
if !host_validation_unenforced(&ctx.settings.environment, &ctx.settings.bind_addr) {
return Vec::new();
}
vec![SystemCheckFinding {
check_id: "settings.host_validation",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"bind_addr `{}` is not loopback, but Host-header validation is only enforced in Environment::Prod. This deployment accepts any Host header (cache-poisoning / poisoned-reset-link risk).",
ctx.settings.bind_addr,
),
hint: Some(
"set UMBRAL_ENVIRONMENT=Prod (enforces allowed_hosts), or bind 127.0.0.1 for local dev."
.to_string(),
),
}]
}
fn host_validation_unenforced(environment: &Environment, bind_addr: &str) -> bool {
!matches!(environment, Environment::Prod) && !is_loopback_bind(bind_addr)
}
fn is_loopback_bind(bind_addr: &str) -> bool {
let host = bind_addr
.rsplit_once(':')
.map(|(host, _)| host)
.unwrap_or(bind_addr)
.trim_start_matches('[')
.trim_end_matches(']');
host == "127.0.0.1" || host == "::1" || host == "localhost" || host.is_empty()
}
fn settings_allowed_hosts(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
if matches!(ctx.settings.environment, Environment::Prod)
&& ctx.settings.allowed_hosts.len() == DEFAULT_ALLOWED_HOSTS.len()
&& ctx
.settings
.allowed_hosts
.iter()
.zip(DEFAULT_ALLOWED_HOSTS.iter())
.all(|(a, b)| a == b)
{
findings.push(SystemCheckFinding {
check_id: "settings.allowed_hosts",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: "Settings.allowed_hosts is still the dev default [\"localhost\", \"127.0.0.1\"] in Environment::Prod. A real production deployment almost certainly serves a public hostname.".to_string(),
hint: Some("set UMBRAL_ALLOWED_HOSTS or `allowed_hosts` in umbral.toml to the hostnames this app actually serves.".to_string()),
});
}
findings
}
fn settings_log_level(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
let level = ctx.settings.log_level.to_ascii_lowercase();
if matches!(ctx.settings.environment, Environment::Prod)
&& (level == "debug" || level == "trace")
{
findings.push(SystemCheckFinding {
check_id: "settings.log_level",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"Settings.log_level is \"{}\" in Environment::Prod. Verbose logging in production leaks internals and adds noise.",
ctx.settings.log_level
),
hint: Some("set UMBRAL_LOG_LEVEL to \"info\", \"warn\", or \"error\" for production deployments.".to_string()),
});
}
findings
}
fn backend_url_scheme_matches_active_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
let scheme = ctx
.settings
.database_url
.split_once(':')
.map(|(s, _)| s)
.unwrap_or("");
let expected_backend = match scheme {
"postgres" | "postgresql" => Some("postgres"),
"sqlite" => Some("sqlite"),
_ => None,
};
if let Some(expected) = expected_backend {
let active = ctx.backend.name();
if expected != active {
findings.push(SystemCheckFinding {
check_id: "backend.url_scheme.matches_active_backend",
severity: Severity::Error,
location: CheckLocation::Settings,
message: format!(
"Settings.database_url scheme \"{scheme}\" implies backend \"{expected}\", but the active backend is \"{active}\"."
),
hint: Some("the URL and the active backend must agree; fix `database_url` in umbral.toml or whichever codepath overrode the backend.".to_string()),
});
}
}
findings
}
fn field_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
let active = ctx.backend.name();
if active == "postgres" {
return findings;
}
if !crate::migrate::is_initialised() {
return findings;
}
for plugin in crate::migrate::registered_plugins() {
for model in crate::migrate::models_for_plugin(&plugin) {
for field in &model.fields {
if !field.supported_backends.is_empty()
&& !field.supported_backends.iter().any(|b| b == active)
{
findings.push(SystemCheckFinding {
check_id: "field.backend",
severity: Severity::Error,
location: CheckLocation::Settings,
message: format!(
"Field `{plugin}::{}::{}` declares `#[umbral(backend = ...)]` \
as {:?}, but the active backend is `{active}`.",
model.name, field.name, field.supported_backends,
),
hint: Some(format!(
"switch UMBRAL_DATABASE_URL to a backend matching one of \
{:?}, or drop the `backend` attribute and pick a portable \
field type.",
field.supported_backends,
)),
});
continue;
}
if is_postgres_only(field.ty) {
findings.push(SystemCheckFinding {
check_id: "field.backend",
severity: Severity::Error,
location: CheckLocation::Settings,
message: format!(
"Field `{plugin}::{}::{}` has type {:?} which is Postgres-only, but the active backend is `{active}`.",
model.name, field.name, field.ty,
),
hint: Some(
"switch UMBRAL_DATABASE_URL to a `postgres://...` URL, \
or change the field to a portable type — \
`serde_json::Value` (SqlType::Json) is the closest \
portable analogue to an array."
.to_string(),
),
});
}
}
}
}
findings
}
fn field_storage_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
if ctx.provides_storage {
return findings;
}
if !crate::migrate::is_initialised() {
return findings;
}
for plugin in crate::migrate::registered_plugins() {
for model in crate::migrate::models_for_plugin(&plugin) {
for field in &model.fields {
let is_file_field = matches!(field.widget.as_deref(), Some("file") | Some("image"));
if !is_file_field {
continue;
}
findings.push(SystemCheckFinding {
check_id: "field.storage_backend",
severity: Severity::Error,
location: CheckLocation::Field {
plugin: Box::leak(plugin.clone().into_boxed_str()),
model: Box::leak(model.name.clone().into_boxed_str()),
field: Box::leak(field.name.clone().into_boxed_str()),
},
message: format!(
"Model `{plugin}::{}` field `{}` declares a file/image field, \
but no Storage backend is registered.",
model.name, field.name,
),
hint: Some(
"add `StoragePlugin` to your app (it registers a filesystem Storage \
backend), or call `umbral::storage::set_storage(...)` before \
`App::build()` to wire a custom backend."
.to_string(),
),
});
}
}
}
findings
}
fn field_choices_default(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
if !crate::migrate::is_initialised() {
return findings;
}
for plugin in crate::migrate::registered_plugins() {
for model in crate::migrate::models_for_plugin(&plugin) {
for field in &model.fields {
if field.choices.is_empty()
|| field.default.is_empty()
|| field.choices.contains(&field.default)
{
continue;
}
let hint = if field.default.contains("::") {
let suggested = field
.default
.rsplit("::")
.next()
.unwrap_or(&field.default)
.to_lowercase();
if field.choices.contains(&suggested) {
format!(
"Did you mean the DB literal `{suggested}`? Choices defaults are \
the stored value (e.g. `\"draft\"`), not the Rust enum path \
(`\"PostStatus::Draft\"`)."
)
} else {
format!(
"Set the default to one of the stored values: [{}].",
field.choices.join(", "),
)
}
} else {
format!(
"Set the default to one of the stored values: [{}].",
field.choices.join(", "),
)
};
findings.push(SystemCheckFinding {
check_id: "field.choices_default",
severity: Severity::Error,
location: CheckLocation::Field {
plugin: Box::leak(plugin.clone().into_boxed_str()),
model: Box::leak(model.name.clone().into_boxed_str()),
field: Box::leak(field.name.clone().into_boxed_str()),
},
message: format!(
"Model `{plugin}::{}` field `{}` has default `{}` which is not one \
of its choices: [{}].",
model.name,
field.name,
field.default,
field.choices.join(", "),
),
hint: Some(hint),
});
}
}
}
findings
}
fn plugin_security_missing(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let names = ctx.registered_plugin_names;
let has_auth = names.contains(&"auth");
let has_sessions = names.contains(&"sessions");
if !(has_auth || has_sessions) {
return Vec::new();
}
if names.contains(&"security") {
return Vec::new();
}
let who = match (has_auth, has_sessions) {
(true, true) => "AuthPlugin and SessionsPlugin are",
(true, false) => "AuthPlugin is",
(false, true) => "SessionsPlugin is",
(false, false) => unreachable!(),
};
vec![SystemCheckFinding {
check_id: "plugin.security_missing",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"{who} mounted without SecurityPlugin — requests have no CSRF \
protection or security headers (CSP, HSTS, X-Frame-Options, …). \
Add `.plugin(SecurityPlugin::new())` to your App builder, or \
handle CSRF / headers through another mechanism.",
),
hint: Some(
"add `.plugin(umbral_security::SecurityPlugin::new())` to your \
`App::builder()` call."
.to_string(),
),
}]
}
fn is_postgres_only(ty: crate::orm::SqlType) -> bool {
use crate::orm::SqlType;
matches!(
ty,
SqlType::Array(_)
| SqlType::Inet
| SqlType::Cidr
| SqlType::MacAddr
| SqlType::Xml
| SqlType::Ltree
| SqlType::Bit
| SqlType::FullText
| SqlType::Decimal
)
}
pub fn run_all(ctx: &CheckContext<'_>, checks: &[SystemCheck]) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
for check in checks {
findings.extend((check.run)(ctx));
}
findings
}
#[cfg(test)]
mod tests {
use super::{host_validation_unenforced, is_loopback_bind};
use crate::settings::Environment;
#[test]
fn loopback_binds_are_recognised() {
assert!(is_loopback_bind("127.0.0.1:8000"));
assert!(is_loopback_bind("localhost:3000"));
assert!(is_loopback_bind("[::1]:8080"));
assert!(is_loopback_bind(":8000")); assert!(!is_loopback_bind("0.0.0.0:8000"));
assert!(!is_loopback_bind("192.168.1.10:8000"));
}
#[test]
fn host_validation_warns_only_off_prod_and_non_loopback() {
assert!(host_validation_unenforced(
&Environment::Dev,
"0.0.0.0:8000"
));
assert!(!host_validation_unenforced(
&Environment::Prod,
"0.0.0.0:8000"
));
assert!(!host_validation_unenforced(
&Environment::Dev,
"127.0.0.1:8000"
));
}
}