Private
Public Access
1
0

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

* 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:
Draco-Lunaris-Echo
2026-06-04 13:28:44 -05:00
committed by GitHub
parent fda70ecf9e
commit 80ffb6b62f
2 changed files with 109 additions and 11 deletions

View File

@ -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.

View File

@ -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;