fix: audit fixes — password zeroize, blur downscale, symlink hardening, error filtering (v0.7.0)
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
All checks were successful
Update PKGBUILD version / update-pkgver (push) Successful in 2s
- Add zeroize dependency, wrap password in Zeroizing<String> from entry extraction through to login_worker (prevents heap-resident plaintext) - Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur to reduce 4K workload - Wallpaper: use symlink_metadata + is_symlink rejection in greeter.rs and config.rs - Avatar: add is_file() check, swap lookup order to ~/.face first (consistent with moonlock/moonset) - greetd errors: show generic fallback in UI, log raw PAM details at debug level only - fprintd: validate device path prefix before creating D-Bus proxy - Locale: cache detected locale via OnceLock (avoid repeated env/file reads)
This commit is contained in:
parent
a2dc89854d
commit
1d557ea135
9
Cargo.lock
generated
9
Cargo.lock
generated
@ -569,7 +569,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.6.1"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gdk4",
|
"gdk4",
|
||||||
@ -585,6 +585,7 @@ dependencies = [
|
|||||||
"systemd-journal-logger",
|
"systemd-journal-logger",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1124,6 +1125,12 @@ version = "0.8.28"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moongreet"
|
name = "moongreet"
|
||||||
version = "0.6.2"
|
version = "0.7.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
|
description = "A greetd greeter for Wayland with GTK4 and Layer Shell"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@ -16,6 +16,7 @@ toml = "0.8"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
graphene-rs = { version = "0.22", package = "graphene-rs" }
|
||||||
|
zeroize = { version = "1", features = ["std"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
systemd-journal-logger = "2.2"
|
systemd-journal-logger = "2.2"
|
||||||
|
|
||||||
|
|||||||
@ -111,14 +111,16 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
|
|||||||
|
|
||||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||||
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> Option<PathBuf> {
|
||||||
// User-configured path
|
// User-configured path — reject symlinks to prevent path traversal
|
||||||
if let Some(ref bg) = config.background_path {
|
if let Some(ref bg) = config.background_path {
|
||||||
let path = PathBuf::from(bg);
|
let path = PathBuf::from(bg);
|
||||||
if path.is_file() {
|
if let Ok(meta) = path.symlink_metadata() {
|
||||||
log::debug!("Wallpaper: using config path {}", path.display());
|
if meta.is_file() && !meta.file_type().is_symlink() {
|
||||||
return Some(path);
|
log::debug!("Wallpaper: using config path {}", path.display());
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log::debug!("Wallpaper: config path {} not found, trying fallbacks", path.display());
|
log::debug!("Wallpaper: config path {} not usable, trying fallbacks", path.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moonarch ecosystem default
|
// Moonarch ecosystem default
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const FPRINTD_MANAGER_IFACE: &str = "net.reactivated.Fprint.Manager";
|
|||||||
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
const FPRINTD_DEVICE_IFACE: &str = "net.reactivated.Fprint.Device";
|
||||||
|
|
||||||
const DBUS_TIMEOUT_MS: i32 = 3000;
|
const DBUS_TIMEOUT_MS: i32 = 3000;
|
||||||
|
const FPRINTD_DEVICE_PREFIX: &str = "/net/reactivated/Fprint/Device/";
|
||||||
|
|
||||||
/// Lightweight fprintd probe — detects device availability and finger enrollment.
|
/// Lightweight fprintd probe — detects device availability and finger enrollment.
|
||||||
/// Does NOT perform verification (that happens through greetd/PAM).
|
/// Does NOT perform verification (that happens through greetd/PAM).
|
||||||
@ -66,6 +67,10 @@ impl FingerprintProbe {
|
|||||||
if device_path.is_empty() {
|
if device_path.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if !device_path.starts_with(FPRINTD_DEVICE_PREFIX) {
|
||||||
|
log::warn!("Unexpected fprintd device path: {device_path}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match gio::DBusProxy::for_bus_future(
|
match gio::DBusProxy::for_bus_future(
|
||||||
gio::BusType::System,
|
gio::BusType::System,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ use std::os::unix::net::UnixStream;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::i18n::{faillock_warning, load_strings, Strings};
|
use crate::i18n::{faillock_warning, load_strings, Strings};
|
||||||
@ -107,14 +108,18 @@ fn is_valid_gtk_theme(name: &str) -> bool {
|
|||||||
|
|
||||||
/// Load background texture from filesystem.
|
/// Load background texture from filesystem.
|
||||||
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
||||||
if let Ok(meta) = std::fs::metadata(bg_path)
|
if let Ok(meta) = std::fs::symlink_metadata(bg_path) {
|
||||||
&& meta.len() > MAX_WALLPAPER_FILE_SIZE
|
if meta.file_type().is_symlink() {
|
||||||
{
|
log::warn!("Rejecting symlink wallpaper: {}", bg_path.display());
|
||||||
log::warn!(
|
return None;
|
||||||
"Wallpaper file too large ({} bytes), skipping: {}",
|
}
|
||||||
meta.len(), bg_path.display()
|
if meta.len() > MAX_WALLPAPER_FILE_SIZE {
|
||||||
);
|
log::warn!(
|
||||||
return None;
|
"Wallpaper file too large ({} bytes), skipping: {}",
|
||||||
|
meta.len(), bg_path.display()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match gdk::Texture::from_filename(bg_path) {
|
match gdk::Texture::from_filename(bg_path) {
|
||||||
Ok(texture) => Some(texture),
|
Ok(texture) => Some(texture),
|
||||||
@ -128,11 +133,18 @@ pub fn load_background_texture(bg_path: &Path) -> Option<gdk::Texture> {
|
|||||||
|
|
||||||
// -- GPU blur via GskBlurNode -------------------------------------------------
|
// -- GPU blur via GskBlurNode -------------------------------------------------
|
||||||
|
|
||||||
|
/// Maximum texture dimension before downscaling for blur.
|
||||||
|
/// Keeps GPU work reasonable on 4K+ displays.
|
||||||
|
const MAX_BLUR_DIMENSION: f32 = 1920.0;
|
||||||
|
|
||||||
/// Render a blurred texture using the GPU via GskBlurNode.
|
/// Render a blurred texture using the GPU via GskBlurNode.
|
||||||
///
|
///
|
||||||
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
||||||
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
|
/// the texture is rendered with padding equal to 3x the blur sigma. The blur
|
||||||
/// is applied to the padded area, then cropped back to the original size.
|
/// is applied to the padded area, then cropped back to the original size.
|
||||||
|
///
|
||||||
|
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
|
||||||
|
/// reduce GPU work. The sigma is scaled proportionally.
|
||||||
fn render_blurred_texture(
|
fn render_blurred_texture(
|
||||||
widget: &impl IsA<gtk::Widget>,
|
widget: &impl IsA<gtk::Widget>,
|
||||||
texture: &gdk::Texture,
|
texture: &gdk::Texture,
|
||||||
@ -141,16 +153,28 @@ fn render_blurred_texture(
|
|||||||
let native = widget.native()?;
|
let native = widget.native()?;
|
||||||
let renderer = native.renderer()?;
|
let renderer = native.renderer()?;
|
||||||
|
|
||||||
let w = texture.width() as f32;
|
let orig_w = texture.width() as f32;
|
||||||
let h = texture.height() as f32;
|
let orig_h = texture.height() as f32;
|
||||||
|
|
||||||
|
// Downscale large textures to reduce GPU blur work
|
||||||
|
let max_dim = orig_w.max(orig_h);
|
||||||
|
let scale = if max_dim > MAX_BLUR_DIMENSION {
|
||||||
|
MAX_BLUR_DIMENSION / max_dim
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let w = (orig_w * scale).round();
|
||||||
|
let h = (orig_h * scale).round();
|
||||||
|
let scaled_sigma = sigma * scale;
|
||||||
|
|
||||||
// Padding must cover the blur kernel radius (typically ~3x sigma)
|
// Padding must cover the blur kernel radius (typically ~3x sigma)
|
||||||
let pad = (sigma * 3.0).ceil();
|
let pad = (scaled_sigma * 3.0).ceil();
|
||||||
|
|
||||||
let snapshot = gtk::Snapshot::new();
|
let snapshot = gtk::Snapshot::new();
|
||||||
// Clip output to original texture size
|
// Clip output to scaled texture size
|
||||||
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
|
snapshot.push_clip(&graphene_rs::Rect::new(pad, pad, w, h));
|
||||||
snapshot.push_blur(sigma as f64);
|
snapshot.push_blur(scaled_sigma as f64);
|
||||||
// Render texture at native size, shifted so edge pixels fill the padding area
|
// Render texture with padding on all sides (edges repeat via oversized bounds)
|
||||||
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
|
snapshot.append_texture(texture, &graphene_rs::Rect::new(-pad, -pad, w + 2.0 * pad, h + 2.0 * pad));
|
||||||
snapshot.pop(); // blur
|
snapshot.pop(); // blur
|
||||||
snapshot.pop(); // clip
|
snapshot.pop(); // clip
|
||||||
@ -477,7 +501,7 @@ pub fn create_greeter_window(
|
|||||||
};
|
};
|
||||||
let Some(user) = user else { return };
|
let Some(user) = user else { return };
|
||||||
|
|
||||||
let password = entry.text().to_string();
|
let password = Zeroizing::new(entry.text().to_string());
|
||||||
|
|
||||||
let session = get_selected_session(&session_dropdown, &sessions_rc);
|
let session = get_selected_session(&session_dropdown, &sessions_rc);
|
||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
@ -884,15 +908,19 @@ fn extract_greetd_description<'a>(response: &'a serde_json::Value, fallback: &'a
|
|||||||
.unwrap_or(fallback)
|
.unwrap_or(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display a greetd error, using a fallback for missing or oversized descriptions.
|
/// Display a greetd error. Logs raw PAM details at debug level,
|
||||||
|
/// shows only the generic fallback in the UI to avoid leaking system info.
|
||||||
fn show_greetd_error(
|
fn show_greetd_error(
|
||||||
error_label: >k::Label,
|
error_label: >k::Label,
|
||||||
password_entry: >k::PasswordEntry,
|
password_entry: >k::PasswordEntry,
|
||||||
response: &serde_json::Value,
|
response: &serde_json::Value,
|
||||||
fallback: &str,
|
fallback: &str,
|
||||||
) {
|
) {
|
||||||
let message = extract_greetd_description(response, fallback);
|
let raw = extract_greetd_description(response, fallback);
|
||||||
show_error(error_label, password_entry, message);
|
if raw != fallback {
|
||||||
|
log::debug!("greetd error detail: {raw}");
|
||||||
|
}
|
||||||
|
show_error(error_label, password_entry, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel any in-progress greetd session.
|
/// Cancel any in-progress greetd session.
|
||||||
@ -961,7 +989,7 @@ fn attempt_login(
|
|||||||
set_login_sensitive(password_entry, session_dropdown, false);
|
set_login_sensitive(password_entry, session_dropdown, false);
|
||||||
|
|
||||||
let username = user.username.clone();
|
let username = user.username.clone();
|
||||||
let password = password.to_string();
|
let password = Zeroizing::new(password.to_string());
|
||||||
let exec_cmd = session.exec_cmd.clone();
|
let exec_cmd = session.exec_cmd.clone();
|
||||||
let session_name = session.name.clone();
|
let session_name = session.name.clone();
|
||||||
let greetd_sock = state.borrow().greetd_sock.clone();
|
let greetd_sock = state.borrow().greetd_sock.clone();
|
||||||
@ -1105,8 +1133,11 @@ fn login_worker(
|
|||||||
return Ok(LoginResult::Cancelled);
|
return Ok(LoginResult::Cancelled);
|
||||||
}
|
}
|
||||||
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
|
if response.get("type").and_then(|v| v.as_str()) == Some("error") {
|
||||||
let message = extract_greetd_description(&response, strings.auth_failed).to_string();
|
let raw = extract_greetd_description(&response, strings.auth_failed);
|
||||||
return Ok(LoginResult::Error { message });
|
if raw != strings.auth_failed {
|
||||||
|
log::debug!("greetd error detail: {raw}");
|
||||||
|
}
|
||||||
|
return Ok(LoginResult::Error { message: strings.auth_failed.to_string() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1194,9 +1225,12 @@ fn login_worker(
|
|||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
let raw = extract_greetd_description(&response, strings.session_start_failed);
|
||||||
|
if raw != strings.session_start_failed {
|
||||||
|
log::debug!("greetd error detail: {raw}");
|
||||||
|
}
|
||||||
return Ok(LoginResult::Error {
|
return Ok(LoginResult::Error {
|
||||||
message: extract_greetd_description(&response, strings.session_start_failed)
|
message: strings.session_start_failed.to_string(),
|
||||||
.to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/i18n.rs
10
src/i18n.rs
@ -4,6 +4,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
|
||||||
|
|
||||||
@ -129,14 +130,17 @@ pub fn detect_locale() -> String {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cached locale — detected once, reused for the lifetime of the process.
|
||||||
|
static CACHED_LOCALE: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
/// Return the string table for the given locale, defaulting to English.
|
/// Return the string table for the given locale, defaulting to English.
|
||||||
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
|
||||||
let locale = match locale {
|
let locale = match locale {
|
||||||
Some(l) => l.to_string(),
|
Some(l) => l,
|
||||||
None => detect_locale(),
|
None => CACHED_LOCALE.get_or_init(detect_locale),
|
||||||
};
|
};
|
||||||
|
|
||||||
match locale.as_str() {
|
match locale {
|
||||||
"de" => &STRINGS_DE,
|
"de" => &STRINGS_DE,
|
||||||
_ => &STRINGS_EN,
|
_ => &STRINGS_EN,
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/users.rs
32
src/users.rs
@ -94,7 +94,7 @@ pub fn get_users(passwd_path: Option<&Path>) -> Vec<User> {
|
|||||||
users
|
users
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find avatar for a user: AccountsService icon > ~/.face > None.
|
/// Find avatar for a user: ~/.face > AccountsService icon > None.
|
||||||
/// Rejects symlinks to prevent path traversal.
|
/// Rejects symlinks to prevent path traversal.
|
||||||
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
pub fn get_avatar_path(username: &str, home: &Path) -> Option<PathBuf> {
|
||||||
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
get_avatar_path_with(username, home, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
|
||||||
@ -106,30 +106,30 @@ pub fn get_avatar_path_with(
|
|||||||
home: &Path,
|
home: &Path,
|
||||||
accountsservice_dir: &Path,
|
accountsservice_dir: &Path,
|
||||||
) -> Option<PathBuf> {
|
) -> Option<PathBuf> {
|
||||||
// AccountsService icon takes priority
|
// ~/.face takes priority (consistent with moonlock/moonset)
|
||||||
|
let face = home.join(".face");
|
||||||
|
if let Ok(meta) = face.symlink_metadata() {
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
|
||||||
|
} else if meta.is_file() {
|
||||||
|
log::debug!("Avatar for {username}: ~/.face {}", face.display());
|
||||||
|
return Some(face);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccountsService icon fallback
|
||||||
if accountsservice_dir.exists() {
|
if accountsservice_dir.exists() {
|
||||||
let icon = accountsservice_dir.join(username);
|
let icon = accountsservice_dir.join(username);
|
||||||
if let Ok(meta) = icon.symlink_metadata() {
|
if let Ok(meta) = icon.symlink_metadata() {
|
||||||
if meta.file_type().is_symlink() {
|
if meta.file_type().is_symlink() {
|
||||||
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
|
log::warn!("Rejecting symlink avatar for {username}: {}", icon.display());
|
||||||
} else {
|
} else if meta.is_file() {
|
||||||
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
|
log::debug!("Avatar for {username}: AccountsService {}", icon.display());
|
||||||
return Some(icon);
|
return Some(icon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ~/.face fallback
|
|
||||||
let face = home.join(".face");
|
|
||||||
if let Ok(meta) = face.symlink_metadata() {
|
|
||||||
if meta.file_type().is_symlink() {
|
|
||||||
log::warn!("Rejecting symlink avatar for {username}: {}", face.display());
|
|
||||||
} else {
|
|
||||||
log::debug!("Avatar for {username}: ~/.face {}", face.display());
|
|
||||||
return Some(face);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::debug!("No avatar found for {username}");
|
log::debug!("No avatar found for {username}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@ -248,7 +248,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accountsservice_icon_takes_priority() {
|
fn face_file_takes_priority_over_accountsservice() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let icons_dir = dir.path().join("icons");
|
let icons_dir = dir.path().join("icons");
|
||||||
fs::create_dir(&icons_dir).unwrap();
|
fs::create_dir(&icons_dir).unwrap();
|
||||||
@ -261,7 +261,7 @@ mod tests {
|
|||||||
fs::write(&face, "fake face").unwrap();
|
fs::write(&face, "fake face").unwrap();
|
||||||
|
|
||||||
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
let path = get_avatar_path_with("testuser", &home, &icons_dir);
|
||||||
assert_eq!(path, Some(icon));
|
assert_eq!(path, Some(face));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user