fix: audit fixes — symlink-safe avatars, blur downscale + padding, config validation (v0.8.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
- Replace canonicalize() with symlink_metadata + is_file + !is_symlink for avatar lookup (prevents symlink traversal to arbitrary files) - Fix blur padding offset from (0,0) to (-pad,-pad) to prevent edge darkening - Add MAX_BLUR_DIMENSION (1920px) downscale before GPU blur - Validate blur per config source (invalid user value preserves system default) - Wallpaper: use symlink_metadata + is_file + !is_symlink in resolve_background_path
This commit is contained in:
parent
f01c6bd25d
commit
8aca2bf331
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -616,7 +616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "moonset"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"gdk-pixbuf",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "moonset"
|
||||
version = "0.7.3"
|
||||
version = "0.8.0"
|
||||
edition = "2024"
|
||||
description = "Wayland session power menu with GTK4 and Layer Shell"
|
||||
license = "MIT"
|
||||
|
||||
@ -37,8 +37,11 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
if parsed.background_path.is_some() {
|
||||
merged.background_path = parsed.background_path;
|
||||
}
|
||||
if parsed.background_blur.is_some() {
|
||||
// Validate blur per source — invalid values preserve the previous default
|
||||
if parsed.background_blur.is_some_and(|b| b.is_finite() && (0.0..=200.0).contains(&b)) {
|
||||
merged.background_blur = parsed.background_blur;
|
||||
} else if parsed.background_blur.is_some() {
|
||||
log::warn!("Invalid background_blur in {}, ignoring", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -48,14 +51,6 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate blur range
|
||||
if let Some(blur) = merged.background_blur {
|
||||
if !blur.is_finite() || blur < 0.0 || blur > 200.0 {
|
||||
log::warn!("Invalid background_blur value {blur}, ignoring");
|
||||
merged.background_blur = None;
|
||||
}
|
||||
}
|
||||
|
||||
merged
|
||||
}
|
||||
|
||||
@ -69,12 +64,14 @@ pub fn resolve_background_path(config: &Config) -> Option<PathBuf> {
|
||||
|
||||
/// Resolve with configurable moonarch wallpaper path (for testing).
|
||||
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 {
|
||||
let path = PathBuf::from(bg);
|
||||
if path.is_file() {
|
||||
log::debug!("Wallpaper source: config ({})", path.display());
|
||||
return Some(path);
|
||||
if let Ok(meta) = path.symlink_metadata() {
|
||||
if meta.is_file() && !meta.file_type().is_symlink() {
|
||||
log::debug!("Wallpaper source: config ({})", path.display());
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
31
src/panel.rs
31
src/panel.rs
@ -103,11 +103,18 @@ pub fn load_background_texture(bg_path: Option<&Path>) -> Option<gdk::Texture> {
|
||||
|
||||
// -- 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.
|
||||
///
|
||||
/// To avoid edge darkening (blur samples transparent pixels outside bounds),
|
||||
/// 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.
|
||||
///
|
||||
/// Large textures (> MAX_BLUR_DIMENSION) are downscaled before blurring to
|
||||
/// reduce GPU work. The sigma is scaled proportionally.
|
||||
fn render_blurred_texture(
|
||||
widget: &impl IsA<gtk::Widget>,
|
||||
texture: &gdk::Texture,
|
||||
@ -116,17 +123,29 @@ fn render_blurred_texture(
|
||||
let native = widget.native()?;
|
||||
let renderer = native.renderer()?;
|
||||
|
||||
let w = texture.width() as f32;
|
||||
let h = texture.height() as f32;
|
||||
let orig_w = texture.width() 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)
|
||||
let pad = (sigma * 3.0).ceil();
|
||||
let pad = (scaled_sigma * 3.0).ceil();
|
||||
|
||||
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_blur(sigma as f64);
|
||||
snapshot.push_blur(scaled_sigma as f64);
|
||||
// Render texture with padding on all sides (edges repeat via oversized bounds)
|
||||
snapshot.append_texture(texture, &graphene_rs::Rect::new(0.0, 0.0, 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(); // clip
|
||||
|
||||
|
||||
48
src/users.rs
48
src/users.rs
@ -47,32 +47,34 @@ pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Find avatar with configurable AccountsService dir (for testing).
|
||||
/// Rejects symlinks to prevent path traversal.
|
||||
pub fn get_avatar_path_with(
|
||||
home: &Path,
|
||||
username: Option<&str>,
|
||||
accountsservice_dir: &Path,
|
||||
) -> Option<PathBuf> {
|
||||
// ~/.face takes priority — canonicalize to resolve symlinks
|
||||
// ~/.face takes priority
|
||||
let face = home.join(".face");
|
||||
if face.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(&face) {
|
||||
log::debug!("Avatar: using ~/.face ({})", canonical.display());
|
||||
return Some(canonical);
|
||||
if let Ok(meta) = face.symlink_metadata() {
|
||||
if meta.file_type().is_symlink() {
|
||||
log::warn!("Rejecting symlink avatar: {}", face.display());
|
||||
} else if meta.is_file() {
|
||||
log::debug!("Avatar: using ~/.face ({})", face.display());
|
||||
return Some(face);
|
||||
}
|
||||
// canonicalize failed (e.g. permissions) — skip rather than return unresolved symlink
|
||||
log::warn!("Avatar: ~/.face exists but canonicalize failed, skipping");
|
||||
}
|
||||
|
||||
// AccountsService icon — also canonicalize for consistency
|
||||
// AccountsService icon fallback
|
||||
if let Some(name) = username {
|
||||
if accountsservice_dir.exists() {
|
||||
let icon = accountsservice_dir.join(name);
|
||||
if icon.exists() {
|
||||
if let Ok(canonical) = std::fs::canonicalize(&icon) {
|
||||
log::debug!("Avatar: using AccountsService icon ({})", canonical.display());
|
||||
return Some(canonical);
|
||||
if let Ok(meta) = icon.symlink_metadata() {
|
||||
if meta.file_type().is_symlink() {
|
||||
log::warn!("Rejecting symlink avatar: {}", icon.display());
|
||||
} else if meta.is_file() {
|
||||
log::debug!("Avatar: using AccountsService icon ({})", icon.display());
|
||||
return Some(icon);
|
||||
}
|
||||
log::warn!("Avatar: AccountsService icon exists but canonicalize failed, skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -107,8 +109,7 @@ mod tests {
|
||||
let face = dir.path().join(".face");
|
||||
fs::write(&face, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
||||
let expected = fs::canonicalize(&face).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -119,8 +120,7 @@ mod tests {
|
||||
let icon = icons_dir.join("testuser");
|
||||
fs::write(&icon, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
||||
let expected = fs::canonicalize(&icon).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(icon));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -133,8 +133,18 @@ mod tests {
|
||||
let icon = icons_dir.join("testuser");
|
||||
fs::write(&icon, "fake image").unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
|
||||
let expected = fs::canonicalize(&face).unwrap();
|
||||
assert_eq!(path, Some(expected));
|
||||
assert_eq!(path, Some(face));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_symlink_avatar() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("secret");
|
||||
fs::write(&target, "secret content").unwrap();
|
||||
let face = dir.path().join(".face");
|
||||
std::os::unix::fs::symlink(&target, &face).unwrap();
|
||||
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
|
||||
assert!(path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user