diff --git a/src/mxpak/config/toml_reader.gleam b/src/mxpak/config/toml_reader.gleam index 9cee153..1b1378a 100644 --- a/src/mxpak/config/toml_reader.gleam +++ b/src/mxpak/config/toml_reader.gleam @@ -2,6 +2,7 @@ //// import gleam/dict +import gleam/list import gleam/option import gleam/result import mxpak/error @@ -93,6 +94,7 @@ fn extract_project_config( Ok(widgets_table) -> parse_widgets(widgets_table) Error(_) -> dict.new() } + use _ <- result.try(validate_widget_names(widgets)) Ok(ProjectConfig( mendix_version: mendix_version, mode: mode, @@ -101,6 +103,14 @@ fn extract_project_config( )) } +fn validate_widget_names( + widgets: dict.Dict(String, WidgetConfig), +) -> Result(Nil, error.Error) { + widgets + |> dict.keys + |> list.try_each(widget.validate_name) +} + fn parse_widgets( table: dict.Dict(String, tom.Toml), ) -> dict.Dict(String, WidgetConfig) { diff --git a/src/mxpak/downloader.gleam b/src/mxpak/downloader.gleam index 4f0a48f..2f16392 100644 --- a/src/mxpak/downloader.gleam +++ b/src/mxpak/downloader.gleam @@ -39,6 +39,7 @@ pub fn download_and_extract( content_id content_id: option.Option(Int), project_root project_root: String, ) -> Result(DownloadResult, error.Error) { + use _ <- result.try(widget.validate_name(name)) let cache_dir = project_root <> "/build/widgets/" <> name use zip_data <- result.try(download_binary(url)) let hash = integrity.sha256(zip_data) @@ -92,6 +93,7 @@ pub fn download_mpk( content_id content_id: option.Option(Int), target_path target_path: String, ) -> Result(DownloadResult, error.Error) { + use _ <- result.try(widget.validate_name(name)) use zip_data <- result.try(download_binary(url)) let hash = integrity.sha256(zip_data) use cached <- result.try(cache.has(hash)) @@ -134,6 +136,7 @@ pub fn restore_from_cache( content_id content_id: option.Option(Int), project_root project_root: String, ) -> Result(DownloadResult, error.Error) { + use _ <- result.try(widget.validate_name(name)) let cache_dir = project_root <> "/build/widgets/" <> name use _ <- result.try(cache.restore(hash, cache_dir)) use has_mjs <- result.try(has_mjs_in_dir(cache_dir)) diff --git a/src/mxpak/widget.gleam b/src/mxpak/widget.gleam index 291f255..d2b1e95 100644 --- a/src/mxpak/widget.gleam +++ b/src/mxpak/widget.gleam @@ -1,6 +1,9 @@ //// Models the runtime kind of a Mendix widget package. //// +import gleam/string +import mxpak/error + /// Represents whether a widget uses the classic or pluggable runtime. pub type Kind { /// A modern pluggable widget containing JavaScript modules. @@ -16,3 +19,33 @@ pub fn is_classic(kind kind: Kind) -> Bool { Pluggable -> False } } + +/// Validates a configured widget name before it is used in a file path. +/// +/// Widget names originate in project TOML files, so they are untrusted path +/// segments. Dotted names remain valid because they are legal TOML section +/// names and safe single path segments. +pub fn validate_name(name name: String) -> Result(Nil, error.Error) { + case name { + "" -> + Error(error.configuration( + "Widget name must not be empty when it is used in a file path", + )) + "." | ".." -> + Error(error.configuration( + "Widget name must not be a relative directory reference: " <> name, + )) + _ -> + case + string.contains(name, "/") + || string.contains(name, "\\") + || string.contains(name, "\u{0000}") + { + True -> + Error(error.configuration( + "Widget name must not contain path separators or NUL: " <> name, + )) + False -> Ok(Nil) + } + } +} diff --git a/test/config_test.gleam b/test/config_test.gleam index 56f2428..93b821d 100644 --- a/test/config_test.gleam +++ b/test/config_test.gleam @@ -2,6 +2,7 @@ //// import gleam/dict +import gleam/list import gleam/option import gleam/string import gleeunit @@ -376,6 +377,50 @@ pub fn env_reader_missing_file_test() -> Nil { Nil } +/// Verifies traversal widget names are rejected at the configuration boundary. +pub fn read_config_rejects_path_traversal_names_test() -> Nil { + let cases = [ + #("../../evil", "parent"), + #("/absolute/evil", "absolute"), + #("..", "dot"), + #("nested/evil", "nested"), + #("nested\\evil", "backslash"), + ] + list.each(cases, fn(pair) { + let #(name, slug) = pair + let dir = "build/test_tmp/traversal_" <> slug + simplifile.create_directory_all(dir) + |> should.be_ok + simplifile.write( + dir <> "/gleam.toml", + "[tools.mxpak.widgets.\"" <> name <> "\"]\nversion = \"1.0.0\"\n", + ) + |> should.be_ok + toml_reader.read_config(dir) + |> should.be_error + simplifile.delete(dir) + |> should.be_ok + }) + Nil +} + +/// Verifies legal dotted widget names remain accepted. +pub fn read_config_accepts_dotted_names_test() -> Nil { + let dir = "build/test_tmp/dotted_name" + simplifile.create_directory_all(dir) + |> should.be_ok + simplifile.write( + dir <> "/gleam.toml", + "[tools.mxpak.widgets.\"com.example.Widget\"]\nversion = \"1.0.0\"\n", + ) + |> should.be_ok + toml_reader.read_config(dir) + |> should.be_ok + simplifile.delete(dir) + |> should.be_ok + Nil +} + fn contains(haystack: String, needle: String) -> Bool { string.contains(haystack, needle) } diff --git a/test/mxpak_test.gleam b/test/mxpak_test.gleam index 6230eeb..5377df2 100644 --- a/test/mxpak_test.gleam +++ b/test/mxpak_test.gleam @@ -1,9 +1,11 @@ //// Tests mxpak behavior for mxpak. //// +import gleam/list import gleeunit import gleeunit/should import mxpak/cli +import mxpak/widget /// Runs this module's test suite. pub fn main() -> Nil { @@ -24,6 +26,18 @@ pub fn parse_version_test() -> Nil { Nil } +/// Verifies widget names are safe path segments. +pub fn widget_validate_name_test() -> Nil { + ["DataGrid", "com.example.Widget", "Chart 2"] + |> list.each(fn(name) { widget.validate_name(name) |> should.be_ok }) + [ + "", ".", "..", "../sibling", "nested/evil", "nested\\evil", "/absolute/evil", + "a\u{0000}b", + ] + |> list.each(fn(name) { widget.validate_name(name) |> should.be_error }) + Nil +} + /// Verifies parse version short behavior. pub fn parse_version_short_test() -> Nil { cli.parse(["-v"])