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
10 changes: 10 additions & 0 deletions src/mxpak/config/toml_reader.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
////

import gleam/dict
import gleam/list
import gleam/option
import gleam/result
import mxpak/error
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/mxpak/downloader.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down
33 changes: 33 additions & 0 deletions src/mxpak/widget.gleam
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
}
}
}
45 changes: 45 additions & 0 deletions test/config_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
////

import gleam/dict
import gleam/list
import gleam/option
import gleam/string
import gleeunit
Expand Down Expand Up @@ -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)
}
14 changes: 14 additions & 0 deletions test/mxpak_test.gleam
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"])
Expand Down
Loading