feat: harden download and install security

This commit is contained in:
stoorps 2026-03-21 20:48:53 +00:00
parent f8ffb95376
commit af13e98eb3
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
33 changed files with 1517 additions and 46 deletions

View file

@ -20,6 +20,7 @@ fn install_succeeds_with_valid_trusted_checksum() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
@ -39,6 +40,7 @@ fn install_succeeds_without_trusted_checksum() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
@ -57,6 +59,7 @@ fn install_fails_before_commit_when_trusted_checksum_mismatches() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some(VALID_FIXTURE_SHA512),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
@ -77,6 +80,7 @@ fn malformed_trusted_checksum_fails_before_commit() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: Some("not-base64"),
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})
@ -87,6 +91,46 @@ fn malformed_trusted_checksum_fails_before_commit() {
assert!(!staged_path.exists());
}
#[test]
fn install_succeeds_with_valid_weak_md5_checksum() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let outcome = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: Some("474a0eb1bbe0a6e62715ce83922a5bf7"),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap();
assert!(outcome.final_payload_path.exists());
}
#[test]
fn install_fails_before_commit_when_weak_md5_checksum_mismatches() {
let root = tempdir().unwrap();
let staged_path = write_staged_payload(root.path(), b"\x7fELFAppImage");
let final_payload_path = root.path().join("payloads/bat.AppImage");
let error = execute_install(&InstallRequest {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: Some("00000000000000000000000000000000"),
desktop: None,
helpers: DesktopHelpers::default(),
})
.unwrap_err();
assert!(matches!(error, PayloadInstallError::WeakChecksumMismatch));
assert!(!final_payload_path.exists());
assert!(!staged_path.exists());
}
fn write_staged_payload(root: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
let staged_path = root.join("staging/bat.download");
fs::create_dir_all(staged_path.parent().unwrap()).unwrap();

View file

@ -57,6 +57,7 @@ fn install_commits_from_staged_payload_path() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: None,
helpers: DesktopHelpers::default(),
})

View file

@ -29,3 +29,19 @@ fn explicit_id_is_treated_as_confident() {
assert_eq!(identity.display_name, "Bat");
assert_eq!(identity.confidence, IdentityConfidence::Confident);
}
#[test]
fn identifiers_containing_dot_dot_are_rejected() {
let error = resolve_identity(
Some("Bat"),
Some(".."),
Some("https://example.com/app.AppImage"),
IdentityFallback::AllowRawUrl,
)
.unwrap_err();
assert_eq!(
error,
aim_core::app::identity::ResolveIdentityError::InvalidStableId
);
}

View file

@ -32,6 +32,7 @@ fn integration_failure_removes_new_payload_and_generated_files() {
staged_payload_path: &staged_path,
final_payload_path: &final_payload_path,
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_entry_path,
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",

View file

@ -30,6 +30,7 @@ fn install_writes_desktop_entry_and_reports_refresh_warning_only() {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
@ -83,6 +84,7 @@ fn install_executes_refresh_helpers_when_available() {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
@ -124,6 +126,7 @@ fn install_extracts_icon_from_appimage_payload_when_icon_path_is_requested() {
staged_payload_path: &staged_path,
final_payload_path: &payload_root.join("bat.AppImage"),
trusted_checksum: None,
weak_checksum_md5: None,
desktop: Some(DesktopIntegrationRequest {
desktop_entry_path: &desktop_root.join("aim-bat.desktop"),
desktop_entry_contents: "[Desktop Entry]\nName=bat\nExec=bat.AppImage\nType=Application\n",
@ -240,6 +243,46 @@ fn install_app_reports_operation_stages_in_order() {
}));
}
#[test]
fn install_app_sanitizes_desktop_entry_display_names() {
let root = tempdir().unwrap();
let mut reporter = Vec::new();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
}
let mut capture = |event: &OperationEvent| reporter.push(event.clone());
let mut plan =
build_add_plan_with_reporter("sharkdp/bat", &FixtureGitHubTransport, &mut capture).unwrap();
plan.display_name_hint = Some("Bat\nExec=evil".to_owned());
let installed = install_app_with_reporter(
"sharkdp/bat",
&plan,
root.path(),
InstallScope::User,
&mut capture,
)
.unwrap();
let desktop_path = installed
.install_outcome
.desktop_entry_path
.as_ref()
.unwrap();
let contents = fs::read_to_string(desktop_path).unwrap();
assert!(contents.contains("Name=Bat Exec=evil"));
assert_eq!(
contents
.lines()
.filter(|line| line.starts_with("Exec="))
.count(),
1
);
}
#[test]
fn gitlab_source_builds_concrete_install_candidate() {
let mut events: Vec<OperationEvent> = Vec::new();

View file

@ -1,5 +1,9 @@
use aim_core::app::progress::{OperationEvent, OperationStage};
use aim_core::app::update::{build_update_plan, execute_updates, execute_updates_with_reporter};
use aim_core::app::add::AddSecurityPolicy;
use aim_core::app::progress::{NoopReporter, OperationEvent, OperationStage};
use aim_core::app::update::{
build_update_plan, execute_updates, execute_updates_with_reporter,
execute_updates_with_reporter_and_policy,
};
use aim_core::domain::app::{AppRecord, InstallMetadata, InstallScope};
use aim_core::domain::source::{NormalizedSourceKind, SourceInputKind, SourceKind, SourceRef};
use aim_core::domain::update::{ChannelPreference, UpdateChannelKind, UpdateStrategy};
@ -311,6 +315,103 @@ fn update_execution_rebuilds_sourceforge_release_folder_without_rewriting_origin
);
}
#[test]
fn direct_http_updates_are_rejected_by_default() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates(std::slice::from_ref(&previous), install_home.path()).unwrap();
assert_eq!(result.updated_count(), 0);
assert_eq!(result.failed_count(), 1);
assert!(matches!(
&result.items[0].status,
aim_core::domain::update::UpdateExecutionStatus::Failed { reason }
if reason.contains("InsecureHttpSource")
));
}
#[test]
fn direct_http_updates_can_be_allowed_by_policy() {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let install_home = tempdir().unwrap();
unsafe {
std::env::set_var("AIM_GITHUB_FIXTURE_MODE", "1");
}
let previous = AppRecord {
stable_id: "url-example.com-downloads-team-app.appimage".to_owned(),
display_name: "team-app".to_owned(),
source_input: Some("http://example.com/downloads/team-app.AppImage".to_owned()),
source: Some(SourceRef {
kind: SourceKind::DirectUrl,
locator: "http://example.com/downloads/team-app.AppImage".to_owned(),
input_kind: SourceInputKind::DirectUrl,
normalized_kind: NormalizedSourceKind::DirectUrl,
canonical_locator: None,
requested_tag: None,
requested_asset_name: None,
tracks_latest: false,
}),
installed_version: Some("unresolved".to_owned()),
update_strategy: None,
metadata: Vec::new(),
install: Some(InstallMetadata {
scope: InstallScope::User,
payload_path: None,
desktop_entry_path: None,
icon_path: None,
}),
};
let result = execute_updates_with_reporter_and_policy(
std::slice::from_ref(&previous),
install_home.path(),
&mut NoopReporter,
AddSecurityPolicy {
allow_http_user_sources: true,
},
)
.unwrap();
assert_eq!(result.updated_count(), 1);
assert_eq!(result.failed_count(), 0);
}
#[test]
fn update_execution_uses_stored_sourceforge_releases_root_for_file_like_inputs() {
let install_home = tempdir().unwrap();