use crate::backend::DatabaseBackend;
use crate::settings::{Environment, Settings};
const INSECURE_DEV_SECRET_KEY: &str = "umbral-insecure-dev-key-change-me";
const MIN_SECRET_KEY_LEN: usize = 32;
fn prod_secret_key_error(env: &Environment, secret_key: &str) -> Option<String> {
if !matches!(env, Environment::Prod) {
return None;
}
if secret_key == INSECURE_DEV_SECRET_KEY {
return Some(
"Settings.secret_key is still set to the insecure dev default in \
Environment::Prod. This is a hard production risk."
.to_string(),
);
}
let len = secret_key.trim().len();
if len < MIN_SECRET_KEY_LEN {
return Some(format!(
"Settings.secret_key is too short ({len} chars) in Environment::Prod; use at least \
{MIN_SECRET_KEY_LEN} random characters. A weak key lets an attacker forge sessions, \
CSRF tokens, and signed values just like the dev default does."
));
}
None
}
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: "model.soft_delete_cascade",
run: soft_delete_cascade_targets,
},
SystemCheck {
id: "model.auto_user",
run: auto_user_columns_nullable,
},
SystemCheck {
id: "model.materialized_view",
run: materialized_view_backend,
},
SystemCheck {
id: "settings.required",
run: settings_required,
},
SystemCheck {
id: "settings.allowed_hosts",
run: settings_allowed_hosts,
},
SystemCheck {
id: "settings.allowed_hosts_wildcard",
run: settings_allowed_hosts_wildcard,
},
SystemCheck {
id: "settings.sqlite_in_prod",
run: settings_sqlite_in_prod,
},
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: "field.case_insensitive.sqlite_ascii",
run: field_case_insensitive_sqlite_ascii,
},
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 let Some(message) =
prod_secret_key_error(&ctx.settings.environment, &ctx.settings.secret_key)
{
findings.push(SystemCheckFinding {
check_id: "settings.required",
severity: Severity::Error,
location: CheckLocation::Settings,
message,
hint: Some("set a long, random UMBRAL_SECRET_KEY (>= 32 chars) 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 {
use std::net::{IpAddr, SocketAddr};
if let Ok(sa) = bind_addr.parse::<SocketAddr>() {
return sa.ip().is_loopback();
}
if let Ok(ip) = bind_addr.parse::<IpAddr>() {
return ip.is_loopback();
}
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 auto_user_columns_nullable(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let Some(models) = crate::migrate::registered_models_opt() else {
return Vec::new();
};
let mut out = Vec::new();
for m in &models {
for col in &m.fields {
if (col.auto_user || col.auto_user_add) && !col.nullable {
out.push(SystemCheckFinding {
check_id: "model.auto_user",
severity: Severity::Error,
location: CheckLocation::Settings,
message: format!(
"`{}.{}` is `#[umbral(auto_user)]` but NOT NULL. Writes without an \
authenticated caller — a background task, a CLI command, a data \
migration, an anonymous request — have no user to stamp, so the \
framework writes NULL there rather than inventing an author. Against a \
NOT NULL column that write fails at runtime.",
m.table, col.name,
),
hint: Some(format!(
"make it nullable: `{}: Option<ForeignKey<AuthUser>>`.",
col.name
)),
});
}
}
}
out
}
fn soft_delete_cascade_targets(_ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
crate::orm::soft_delete_cascade::check_cascade_targets()
.into_iter()
.map(|message| SystemCheckFinding {
check_id: "model.soft_delete_cascade",
severity: Severity::Error,
location: CheckLocation::Settings,
message,
hint: Some(
"mark the child `#[umbral(soft_delete)]` so the cascade can follow it, or change \
the FK to `on_delete = \"set_null\"` / `\"restrict\"` if the child is meant to \
outlive its parent."
.to_string(),
),
})
.collect()
}
fn settings_allowed_hosts_wildcard(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
if !allowed_hosts_has_wildcard(&ctx.settings.environment, &ctx.settings.allowed_hosts) {
return Vec::new();
}
vec![SystemCheckFinding {
check_id: "settings.allowed_hosts_wildcard",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: "Settings.allowed_hosts contains the \"*\" wildcard in Environment::Prod — the \
Host-header guard accepts ANY Host, defeating host validation (cache-poisoning / \
poisoned-reset-link risk)."
.to_string(),
hint: Some(
"list the exact hostnames this app serves in UMBRAL_ALLOWED_HOSTS instead of \"*\"."
.to_string(),
),
}]
}
fn allowed_hosts_has_wildcard(environment: &Environment, allowed_hosts: &[String]) -> bool {
matches!(environment, Environment::Prod) && allowed_hosts.iter().any(|h| h.trim() == "*")
}
fn settings_sqlite_in_prod(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
if !is_sqlite_in_prod(&ctx.settings.environment, &ctx.settings.database_url) {
return Vec::new();
}
vec![SystemCheckFinding {
check_id: "settings.sqlite_in_prod",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"database_url `{}` is SQLite in Environment::Prod. SQLite is the dev/test backend \
(single writer, no network concurrency); production traffic wants Postgres.",
redact_url_userinfo(&ctx.settings.database_url),
),
hint: Some(
"set UMBRAL_DATABASE_URL to a `postgres://...` URL for production, or keep SQLite \
deliberately for a small single-node deployment."
.to_string(),
),
}]
}
fn is_sqlite_in_prod(environment: &Environment, database_url: &str) -> bool {
matches!(environment, Environment::Prod)
&& database_url
.split_once(':')
.map(|(scheme, _)| scheme.eq_ignore_ascii_case("sqlite"))
.unwrap_or(false)
}
fn redact_url_userinfo(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let after = scheme_end + 3;
let authority_end = url[after..]
.find(['/', '?', '#'])
.map(|i| after + i)
.unwrap_or(url.len());
match url[after..authority_end].find('@') {
Some(at) => format!("{}***{}", &url[..after], &url[after + at..]),
None => url.to_string(),
}
}
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 materialized_view_backend(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
if ctx.backend.name() == "postgres" {
return findings;
}
if !crate::migrate::is_initialised() {
return findings;
}
let active = ctx.backend.name();
for plugin in crate::migrate::registered_plugins() {
for model in crate::migrate::models_for_plugin(&plugin) {
if !model.materialized {
continue;
}
findings.push(SystemCheckFinding {
check_id: "model.materialized_view",
severity: Severity::Error,
location: CheckLocation::Settings,
message: format!(
"Model `{plugin}::{}` declares `#[umbral(materialized_view = ...)]`, \
but the active backend is `{active}`, which has no materialized views.",
model.name,
),
hint: Some(
"switch UMBRAL_DATABASE_URL to a `postgres://...` URL, or change the \
attribute to `#[umbral(view = ...)]` — a plain view is portable. Note \
that a plain view recomputes on every read, which is the cost a \
materialized view exists to avoid: make that trade deliberately, do \
not let the backend make it for you."
.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_case_insensitive_sqlite_ascii(ctx: &CheckContext<'_>) -> Vec<SystemCheckFinding> {
let mut findings = Vec::new();
if ctx.backend.name() != "sqlite" {
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.case_insensitive {
continue;
}
findings.push(SystemCheckFinding {
check_id: "field.case_insensitive.sqlite_ascii",
severity: Severity::Warning,
location: CheckLocation::Settings,
message: format!(
"Field `{plugin}::{}::{}` is `#[umbral(case_insensitive)]`, but the active \
backend is SQLite, whose `COLLATE NOCASE` folds ASCII A–Z only — \
non-ASCII letters are compared case-sensitively.",
model.name, field.name,
),
hint: Some(
"Fine for ASCII usernames/emails/slugs. If you need Unicode-correct \
case-folding, use Postgres (citext folds per the DB collation), or \
normalize with `#[umbral(lowercase)]` (Rust's to_lowercase is \
Unicode-aware) when preserving the original casing isn't required."
.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::{
allowed_hosts_has_wildcard, host_validation_unenforced, is_loopback_bind,
is_sqlite_in_prod, prod_secret_key_error, redact_url_userinfo,
};
use crate::settings::Environment;
#[test]
fn prod_rejects_weak_and_default_secret_keys() {
assert!(
prod_secret_key_error(&Environment::Prod, "umbral-insecure-dev-key-change-me")
.is_some()
);
assert!(
prod_secret_key_error(&Environment::Prod, "x").is_some(),
"a trivially short secret_key must be rejected in Prod"
);
assert!(
prod_secret_key_error(&Environment::Prod, "0123456789abcdef0123456789abcdef0123")
.is_none()
);
assert!(prod_secret_key_error(&Environment::Dev, "x").is_none());
}
#[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("::1"));
assert!(is_loopback_bind("127.0.0.1"));
assert!(!is_loopback_bind("0.0.0.0:8000"));
assert!(!is_loopback_bind("192.168.1.10:8000"));
assert!(!is_loopback_bind("[2001:db8::1]: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"
));
}
#[test]
fn wildcard_allowed_hosts_flagged_only_in_prod() {
let with_star = vec!["example.com".to_string(), "*".to_string()];
let no_star = vec!["example.com".to_string()];
assert!(allowed_hosts_has_wildcard(&Environment::Prod, &with_star));
assert!(allowed_hosts_has_wildcard(
&Environment::Prod,
&[" * ".to_string()]
));
assert!(!allowed_hosts_has_wildcard(&Environment::Prod, &no_star));
assert!(!allowed_hosts_has_wildcard(&Environment::Dev, &with_star));
}
#[test]
fn sqlite_in_prod_flagged() {
assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite::memory:"));
assert!(is_sqlite_in_prod(&Environment::Prod, "sqlite://app.db"));
assert!(!is_sqlite_in_prod(
&Environment::Prod,
"postgres://host/app"
));
assert!(!is_sqlite_in_prod(&Environment::Dev, "sqlite::memory:"));
}
#[test]
fn redact_url_userinfo_masks_password() {
assert_eq!(
redact_url_userinfo("postgres://u:p@host/db"),
"postgres://***@host/db"
);
assert_eq!(redact_url_userinfo("sqlite::memory:"), "sqlite::memory:");
}
}