From a616126210b2adbb1b1e38bbc8b728dba51311af Mon Sep 17 00:00:00 2001 From: GGOBP Date: Thu, 3 Sep 2026 09:42:01 +0900 Subject: [PATCH] Validate MPK entry paths before extraction writes Fixes glendix-labs/mendraw#3 --- src/mxpak/mpk/zip.gleam | 25 +++++++-- test/mpk_test.gleam | 109 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/mxpak/mpk/zip.gleam b/src/mxpak/mpk/zip.gleam index 9d9c3f9..155384e 100644 --- a/src/mxpak/mpk/zip.gleam +++ b/src/mxpak/mpk/zip.gleam @@ -3,6 +3,7 @@ import gleam/list import gleam/result +import gleam/string import mxpak/error /// A typed `ZipEntry` value used by the zip capability. @@ -15,11 +16,11 @@ pub type ZipEntry { pub fn extract( zip_binary zip_binary: BitArray, ) -> Result(List(ZipEntry), error.Error) { - unzip_to_memory(zip_binary) - |> result.map(fn(raw_entries) { - list.map(raw_entries, fn(entry) { - ZipEntry(name: entry.0, content: entry.1) - }) + use raw_entries <- result.try(unzip_to_memory(zip_binary)) + raw_entries + |> list.try_map(fn(entry) { + use _ <- result.try(validate_entry_name(entry.0)) + Ok(ZipEntry(name: entry.0, content: entry.1)) }) } @@ -42,6 +43,20 @@ pub fn list_entries( list.map(entries, fn(e) { e.name }) } +/// Rejects archive paths that could escape an extraction root. +fn validate_entry_name(name name: String) -> Result(Nil, error.Error) { + let segments = string.split(name, "/") + case + name == "" + || string.starts_with(name, "/") + || string.contains(name, "\\") + || list.contains(segments, "..") + { + True -> Error(error.download("ZIP 엔트리 경로가 안전하지 않습니다: " <> name)) + False -> Ok(Nil) + } +} + // -- Erlang FFI -- @external(erlang, "mxpak_zip_ffi", "unzip_to_memory") fn unzip_to_memory( diff --git a/test/mpk_test.gleam b/test/mpk_test.gleam index fcc62ef..70f7680 100644 --- a/test/mpk_test.gleam +++ b/test/mpk_test.gleam @@ -29,7 +29,50 @@ pub fn zip_extract_file_from_invalid_test() -> Nil { Nil } -/// Verifies parse widget name behavior. +/// Verifies OTP sanitizes parent-directory entries to in-root names. +pub fn zip_extract_normalizes_parent_directory_entries_test() -> Nil { + let entries = zip.extract(parent_directory_zip_binary()) |> should.be_ok + entries + |> list.first + |> should.equal( + Ok(zip.ZipEntry(name: "evil.txt", content: <<"escaped":utf8>>)), + ) + Nil +} + +/// Verifies backslash archive entries are rejected before extraction. +pub fn zip_extract_rejects_backslash_entries_test() -> Nil { + let error_value = zip.extract(backslash_zip_binary()) |> should.be_error + error_value + |> error.message + |> string.contains("..\\evil.txt") + |> should.be_true + Nil +} + +/// Verifies nested archive entries remain usable. +pub fn zip_extract_allows_nested_entries_test() -> Nil { + let entries = zip.extract(valid_zip_binary()) |> should.be_ok + entries + |> list.first + |> should.equal( + Ok( + zip.ZipEntry(name: "com/example/Widget.mjs", content: << + "export default 1\n":utf8, + >>), + ), + ) + Nil +} + +/// Verifies truncated archives fail without crashing. +pub fn zip_extract_truncated_archive_test() -> Nil { + zip.extract(truncated_zip_binary()) + |> should.be_error + Nil +} + +// A stored ZIP archive containing one ../evil.txt entry. pub fn parse_widget_name_test() -> Nil { let xml = "DataGrid" xml.parse_widget_name(xml) @@ -191,3 +234,67 @@ pub fn metadata_classic_roundtrip_test() -> Nil { |> should.be_ok Nil } + +fn parent_directory_zip_binary() -> BitArray { + << + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, + 0x5d, 0x5b, 0x0c, 0xf8, 0x92, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x0b, 0x00, 0x00, 0x00, 0x2e, 0x2e, 0x2f, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x74, + 0x78, 0x74, 0x65, 0x73, 0x63, 0x61, 0x70, 0x65, 0x64, 0x50, 0x4b, 0x01, 0x02, + 0x14, 0x03, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, 0x5d, 0x5b, + 0x0c, 0xf8, 0x92, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x0b, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x2e, 0x2e, 0x2f, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x74, 0x78, + 0x74, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x39, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + >> +} + +// A stored ZIP archive containing one ..\evil.txt entry. +fn backslash_zip_binary() -> BitArray { + << + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x4d, 0x23, + 0x5d, 0x5b, 0x0c, 0xf8, 0x92, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, + 0x0b, 0x00, 0x00, 0x00, 0x2e, 0x2e, 0x5c, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x74, + 0x78, 0x74, 0x65, 0x73, 0x63, 0x61, 0x70, 0x65, 0x64, 0x50, 0x4b, 0x01, 0x02, + 0x14, 0x03, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2a, 0x4d, 0x23, 0x5d, 0x5b, + 0x0c, 0xf8, 0x92, 0x07, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x0b, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x2e, 0x2e, 0x5c, 0x65, 0x76, 0x69, 0x6c, 0x2e, 0x74, 0x78, + 0x74, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x39, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, + >> +} + +// A stored ZIP archive containing one nested widget entry. +fn valid_zip_binary() -> BitArray { + << + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, + 0x5d, 0xbc, 0x6c, 0xb4, 0x4d, 0x11, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, + 0x16, 0x00, 0x00, 0x00, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, + 0x6c, 0x65, 0x2f, 0x57, 0x69, 0x64, 0x67, 0x65, 0x74, 0x2e, 0x6d, 0x6a, 0x73, + 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, + 0x74, 0x20, 0x31, 0x0a, 0x50, 0x4b, 0x01, 0x02, 0x14, 0x03, 0x14, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, 0x5d, 0xbc, 0x6c, 0xb4, 0x4d, 0x11, 0x00, + 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, 0x16, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x63, 0x6f, + 0x6d, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2f, 0x57, 0x69, 0x64, + 0x67, 0x65, 0x74, 0x2e, 0x6d, 0x6a, 0x73, 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x44, 0x00, 0x00, 0x00, 0x45, 0x00, 0x00, + 0x00, 0x00, 0x00, + >> +} + +fn truncated_zip_binary() -> BitArray { + << + 0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, + 0x5d, 0xbc, 0x6c, 0xb4, 0x4d, 0x11, 0x00, 0x00, 0x00, 0x11, 0x00, 0x00, 0x00, + 0x16, 0x00, 0x00, 0x00, 0x63, 0x6f, 0x6d, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, + 0x6c, 0x65, 0x2f, 0x57, 0x69, 0x64, 0x67, 0x65, 0x74, 0x2e, 0x6d, 0x6a, 0x73, + 0x65, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, + 0x74, 0x20, 0x31, 0x0a, 0x50, 0x4b, 0x01, 0x02, 0x14, 0x03, 0x14, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xf4, 0x4c, 0x23, 0x5d, 0xbc, 0x6c, 0xb4, 0x4d, 0x11, 0x00, + 0x00, 0x00, + >> +} +/// Verifies parse widget name behavior.