feat(security): replace hardcoded admin password with in-app bootstrap (#25)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 50s
CI Pipeline / Rust Unit Tests (push) Successful in 1m8s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped
* feat(security): replace hardcoded admin password with in-app bootstrap (issue #8) Replace the publicly-known Argon2id hash in 002_seed_admin.sql with a clearly-invalid placeholder that cannot validate any password (fail-closed). On first startup, pm-web detects the placeholder and generates a random 24-character alphanumeric password, hashes it with Argon2id, and UPDATEs the admin row. The plaintext password is printed once to stderr (visible in systemd journal). This eliminates the need for a separate hash_password binary, shell script SQL injection risk, and password leakage in shell variables. Closes #8 * fix(security): rustfmt compliance for bootstrap function * fix(security): add trailing commas to match arms per rustfmt
This commit is contained in:
committed by
GitHub
parent
fda70ecf9e
commit
80ffb6b62f
@ -1,19 +1,21 @@
|
|||||||
//! pm-web — Linux Patch Manager web server.
|
//! pm-web — Linux Patch Manager web server.
|
||||||
|
|
||||||
mod secret_key;
|
|
||||||
|
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
|
mod secret_key;
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use pm_auth::{
|
use pm_auth::{
|
||||||
jwt,
|
jwt,
|
||||||
|
password::hash_password,
|
||||||
rbac::{require_auth, AuthConfig},
|
rbac::{require_auth, AuthConfig},
|
||||||
};
|
};
|
||||||
use pm_core::{
|
use pm_core::{
|
||||||
config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware,
|
config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware,
|
||||||
};
|
};
|
||||||
|
use rand::Rng;
|
||||||
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
|
||||||
use routes::ws::WsTicket;
|
use routes::ws::WsTicket;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@ -27,6 +29,93 @@ use tower_http::{
|
|||||||
trace::TraceLayer,
|
trace::TraceLayer,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8).
|
||||||
|
/// Detecting this prefix means the admin password has not been bootstrapped yet.
|
||||||
|
const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA";
|
||||||
|
|
||||||
|
/// Bootstrap the default admin account with a random password.
|
||||||
|
///
|
||||||
|
/// On first startup after a fresh install, the `users` table contains the seed
|
||||||
|
/// admin row with a clearly-invalid placeholder hash (cannot validate any password).
|
||||||
|
/// This function detects that placeholder, generates a cryptographically random
|
||||||
|
/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row.
|
||||||
|
///
|
||||||
|
/// The plaintext password is printed **once** to stderr (visible in `systemctl status`
|
||||||
|
/// or `journalctl`) and is never stored on disk.
|
||||||
|
///
|
||||||
|
/// If the admin row already has a real hash, this function is a no-op.
|
||||||
|
async fn bootstrap_admin_password(pool: &sqlx::PgPool) {
|
||||||
|
// Check if the admin account still has the placeholder hash.
|
||||||
|
let result: Option<String> = sqlx::query_scalar(
|
||||||
|
"SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'",
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let current_hash = match result {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return, // No admin row — nothing to bootstrap.
|
||||||
|
};
|
||||||
|
|
||||||
|
if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) {
|
||||||
|
// Admin already has a real password — nothing to do.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a 24-character random alphanumeric password.
|
||||||
|
let password: String = rand::thread_rng()
|
||||||
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
|
.take(24)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Hash it with the application's Argon2id parameters.
|
||||||
|
let new_hash = match hash_password(&password) {
|
||||||
|
Ok(h) => h,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to hash bootstrap admin password");
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replace the placeholder hash with the real one.
|
||||||
|
// The WHERE clause matches the placeholder prefix to ensure idempotency.
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"UPDATE users
|
||||||
|
SET password_hash = $1
|
||||||
|
WHERE username = 'admin'
|
||||||
|
AND auth_provider = 'local'
|
||||||
|
AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#,
|
||||||
|
)
|
||||||
|
.bind(&new_hash)
|
||||||
|
.execute(pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(result) if result.rows_affected() == 1 => {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!(" INITIAL ADMIN PASSWORD (shown once)");
|
||||||
|
eprintln!(" Username: admin");
|
||||||
|
eprintln!(" Password: {}", password);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" You will be forced to change this on first login.");
|
||||||
|
eprintln!(" If lost, restart the service to generate a new one.");
|
||||||
|
eprintln!("========================================");
|
||||||
|
eprintln!();
|
||||||
|
tracing::info!("Bootstrap admin password generated and set");
|
||||||
|
},
|
||||||
|
Ok(_) => {
|
||||||
|
// Rows affected != 1 — concurrent bootstrap or already replaced.
|
||||||
|
tracing::info!("Admin password already bootstrapped (concurrent or prior)");
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "Failed to update admin password hash");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared application state threaded through Axum.
|
/// Shared application state threaded through Axum.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@ -94,6 +183,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let pool = db::init_pool(&config.database).await?;
|
let pool = db::init_pool(&config.database).await?;
|
||||||
db::run_migrations(&pool).await?;
|
db::run_migrations(&pool).await?;
|
||||||
|
|
||||||
|
// Bootstrap admin password if the seed admin still has the placeholder hash (issue #8).
|
||||||
|
bootstrap_admin_password(&pool).await;
|
||||||
|
|
||||||
// Initialise the internal CA using the configured certificate paths.
|
// Initialise the internal CA using the configured certificate paths.
|
||||||
// The CA certificate and key must exist at the configured locations and be
|
// The CA certificate and key must exist at the configured locations and be
|
||||||
// unencrypted PEM. If absent, a new CA is generated in that directory.
|
// unencrypted PEM. If absent, a new CA is generated in that directory.
|
||||||
|
|||||||
@ -1,12 +1,17 @@
|
|||||||
-- Migration: 002_seed_admin
|
-- Migration: 002_seed_admin
|
||||||
-- Description: Seed the default admin account.
|
-- Description: Seed the default admin account.
|
||||||
--
|
--
|
||||||
-- Default credentials (CHANGE BEFORE PRODUCTION USE):
|
-- IMPORTANT (issue #8): The password_hash below is a PLACEHOLDER
|
||||||
-- Username: admin
|
-- that cannot validate any password. On first startup, pm-web detects
|
||||||
-- Password: ChangeMe123!
|
-- this placeholder and generates a random admin password, replacing
|
||||||
|
-- the hash in the database. The generated password is printed once
|
||||||
|
-- to stderr (visible in systemd journal).
|
||||||
--
|
--
|
||||||
-- The password hash below is Argon2id of "ChangeMe123!" with
|
-- If the application never starts (e.g., manual migration only),
|
||||||
-- m=65536, t=3, p=1. Replace after first login.
|
-- the admin account is inaccessible — this is fail-closed.
|
||||||
|
--
|
||||||
|
-- On first successful login with a real password, the admin is forced to
|
||||||
|
-- set a new password (force_password_reset = TRUE).
|
||||||
|
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
id,
|
id,
|
||||||
@ -27,10 +32,11 @@ VALUES (
|
|||||||
'admin@localhost',
|
'admin@localhost',
|
||||||
'admin',
|
'admin',
|
||||||
'local',
|
'local',
|
||||||
-- Argon2id hash of "ChangeMe123!" — REPLACE IN PRODUCTION
|
-- PLACEHOLDER Argon2id hash (issue #8). Cannot validate any password.
|
||||||
'$argon2id$v=19$m=65536,t=3,p=1$Kv8bkGiE81yIuXARq9fwsw$NrBRFvgL1dVsW7bEK6NxEOzIX2q1p4B0K422idAVIDQ',
|
-- pm-web replaces this with a real hash on first startup.
|
||||||
FALSE, -- MFA disabled by default; admin must set up on first login
|
'$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
||||||
|
FALSE,
|
||||||
TRUE,
|
TRUE,
|
||||||
TRUE -- Force password reset on first login
|
TRUE
|
||||||
)
|
)
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|||||||
Reference in New Issue
Block a user