Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security

- Extension resolution now rejects releases without a host-supported MEP version, and v2 exact locks authenticate launch arguments, capabilities, and MEP versions before activation; legacy v1 locks are rejected explicitly
- `morphir-common` and `morphir-distribution` move from `zip` 8.6.0 to 9.2.1 for [GHSA-m665-v7pm-7grj](https://github.com/advisories/GHSA-m665-v7pm-7grj). Both ZIP extractors now call `ZipArchive::validate_header_consistency`, which `zip` does not run by default, so they reject an archive whose local headers and central directory disagree, including duplicate central names, overlapping entries and local headers that the central directory does not list. Before this change those archives extracted under the central directory names

## [0.2.0] - 2026-01-24

Expand Down
68 changes: 52 additions & 16 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/morphir-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ reqwest = { version = "0.13", default-features = false, features = [
"json",
] }
flate2 = "1.0"
zip = "8.0"
zip = "9.2.1"
tar = "0.4"
tempfile = "3"

Expand Down
137 changes: 137 additions & 0 deletions crates/morphir-common/src/remote/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ impl HttpFetcher {
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| RemoteSourceError::ArchiveError(format!("Failed to open zip: {}", e)))?;
// `ZipArchive` reads names from the central directory only, and does not check them
// against the local headers unless asked (GHSA-m665-v7pm-7grj).
archive.validate_header_consistency().map_err(|e| {
RemoteSourceError::ArchiveError(format!("Inconsistent zip headers: {}", e))
})?;

for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| {
Expand Down Expand Up @@ -311,4 +316,136 @@ mod tests {
}

// Note: Additional tests would require a mock HTTP server

mod extract_zip {
use super::super::{HttpFetcher, RemoteSourceError};
use std::fs;
use std::io::Write;
use std::path::Path;
use zip::write::SimpleFileOptions;

const CENTRAL_DIRECTORY_HEADER: &[u8] = b"PK\x01\x02";
const CENTRAL_NAME_OFFSET: usize = 46;

fn extract(archive: &Path, destination: &Path) -> crate::remote::error::Result<()> {
HttpFetcher::with_defaults()
.unwrap()
.extract_zip(archive, destination)
}

fn an_archive_with_entries(path: &Path, entries: &[(&str, &[u8])]) {
let mut writer = zip::ZipWriter::new(fs::File::create(path).unwrap());
for (name, bytes) in entries {
writer
.start_file(*name, SimpleFileOptions::default())
.unwrap();
writer.write_all(bytes).unwrap();
}
writer.finish().unwrap();
}

/// One entry whose local header says `local_name` and whose central directory says
/// `central_name`. The names must have the same length.
fn an_archive_whose_headers_disagree_on_the_name(
path: &Path,
local_name: &str,
central_name: &str,
) {
assert_eq!(local_name.len(), central_name.len());
an_archive_with_entries(path, &[(local_name, b"payload")]);
let mut bytes = fs::read(path).unwrap();
let central = bytes
.windows(CENTRAL_DIRECTORY_HEADER.len())
.position(|window| window == CENTRAL_DIRECTORY_HEADER)
.unwrap()
+ CENTRAL_NAME_OFFSET;
bytes[central..central + central_name.len()].copy_from_slice(central_name.as_bytes());
fs::write(path, bytes).unwrap();
}

#[test]
fn a_well_formed_archive_is_extracted() {
let root = tempfile::tempdir().unwrap();
let archive = root.path().join("source.zip");
let destination = root.path().join("out");
fs::create_dir(&destination).unwrap();
an_archive_with_entries(&archive, &[("dir/file.txt", b"contents")]);

extract(&archive, &destination).unwrap();

assert_eq!(
fs::read(destination.join("dir/file.txt")).unwrap(),
b"contents"
);
}

#[test]
fn an_archive_whose_headers_disagree_on_a_name_is_rejected() {
// zip 8.6.0, and 9.2.1 without the header check, extract this under the central name.
let root = tempfile::tempdir().unwrap();
let archive = root.path().join("cloaked.zip");
let destination = root.path().join("out");
fs::create_dir(&destination).unwrap();
an_archive_whose_headers_disagree_on_the_name(&archive, "../a.txt", "safe.txt");

let error = extract(&archive, &destination).unwrap_err();

assert!(matches!(error, RemoteSourceError::ArchiveError(_)));
assert!(!destination.join("safe.txt").exists());
assert!(!root.path().join("a.txt").exists());
}

#[test]
fn an_entry_that_escapes_the_root_is_skipped() {
let root = tempfile::tempdir().unwrap();
let archive = root.path().join("escape.zip");
let destination = root.path().join("out");
fs::create_dir(&destination).unwrap();
an_archive_with_entries(
&archive,
&[("../escape.txt", b"escape"), ("safe.txt", b"safe")],
);

extract(&archive, &destination).unwrap();

assert!(!root.path().join("escape.txt").exists());
assert_eq!(fs::read(destination.join("safe.txt")).unwrap(), b"safe");
}

#[cfg(unix)]
#[test]
fn unix_modes_are_applied_and_a_symlink_entry_stays_a_regular_file() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let archive = root.path().join("modes.zip");
let destination = root.path().join("out");
fs::create_dir(&destination).unwrap();
let mut writer = zip::ZipWriter::new(fs::File::create(&archive).unwrap());
writer
.start_file(
"run.sh",
SimpleFileOptions::default().unix_permissions(0o750),
)
.unwrap();
writer.write_all(b"#!/bin/sh\n").unwrap();
writer
.add_symlink("link", "/etc/passwd", SimpleFileOptions::default())
.unwrap();
writer.finish().unwrap();

extract(&archive, &destination).unwrap();

assert_eq!(
fs::metadata(destination.join("run.sh"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o750
);
let link = fs::symlink_metadata(destination.join("link")).unwrap();
assert!(!link.file_type().is_symlink());
assert_eq!(fs::read(destination.join("link")).unwrap(), b"/etc/passwd");
}
}
}
2 changes: 1 addition & 1 deletion crates/morphir-distribution/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ tracing = "0.1"
unicode-casefold = { workspace = true }
unicode-normalization = { workspace = true }
url = "2.5.8"
zip = "8.0"
zip = "9.2.1"
21 changes: 17 additions & 4 deletions crates/morphir-distribution/src/tool_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ pub(crate) fn extract_zip(
format!("archive exceeds {MAX_ARCHIVE_ENTRIES} entries"),
));
}
// `ZipArchive` reads names from the central directory only, and does not check them against
// the local headers unless asked (GHSA-m665-v7pm-7grj).
archive
.validate_header_consistency()
.map_err(|source| unsafe_archive("", format!("inconsistent ZIP headers: {source}")))?;

let mut names = PortableArchivePaths::default();
let mut unpacked = 0_u64;
Expand All @@ -58,7 +63,10 @@ pub(crate) fn extract_zip(
let mut entry = archive
.by_index(index)
.map_err(|source| unsafe_archive("", format!("invalid ZIP entry: {source}")))?;
let raw_name = entry.name().to_owned();
let raw_name = entry
.name()
.map_err(|source| unsafe_archive("", format!("invalid ZIP entry name: {source}")))?
.into_owned();
let enclosed = entry.enclosed_name().ok_or_else(|| {
unsafe_archive(&raw_name, "entry escapes the package root".to_owned())
})?;
Expand Down Expand Up @@ -542,9 +550,14 @@ pub(crate) fn copy_zip_entry<R: Read, W: io::Write>(
));
}
let actual = io::copy(&mut input.take(declared_size + 1), output).map_err(|source| {
DistributionError::Io {
path: output_path.to_path_buf(),
source,
// `zip` 9 fails the read itself when an entry expands past its declared size.
if source.kind() == io::ErrorKind::InvalidData {
unsafe_archive(entry_name, format!("invalid ZIP entry data: {source}"))
} else {
DistributionError::Io {
path: output_path.to_path_buf(),
source,
}
}
})?;
if actual != declared_size {
Expand Down
1 change: 1 addition & 0 deletions crates/morphir-distribution/src/tool_state/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod archive_portability;
mod package_namespacing;
mod rollback_history;
mod support;
mod zip_extraction_safety;

use super::*;
use crate::{Channel, RelativeArtifactPath, Selection, Sha256Digest, ToolId};
Expand Down
Loading
Loading