CVE-2026-32685
Description
Path traversal vulnerability in Gleam's handling of custom documentation pages allows arbitrary file read and file write outside the intended documentation output directory.
The documentation.pages entries from gleam.toml are incorporated into filesystem paths without sufficient validation or confinement to the intended project and documentation output directories. The documentation.pages[].path field can be used to write generated documentation files outside the intended build/dev/docs// output directory. The documentation.pages[].source field can be used to read files outside the project directory and embed their contents into generated documentation output.
An attacker who can convince a victim to run gleam docs build on an untrusted project, or with untrusted gleam.toml content, can cause local files readable by the victim to be included in generated documentation artifacts, and can cause generated documentation files to be written outside the intended docs output directory.
This issue affects Gleam from 1.16.0 until 1.17.0.
Affected products
1- Range: 1.16.0 - 1.17.0
Patches
281570611906bCheck docs path on deserialise
13 files changed · +290 −215
compiler-cli/src/docs.rs+8 −215 modified@@ -1,6 +1,6 @@ use std::{collections::HashMap, time::SystemTime}; -use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; +use camino::{Utf8Path, Utf8PathBuf}; use ecow::EcoString; use crate::{cli, fs::ProjectIO, http::HttpClient}; @@ -10,7 +10,7 @@ use gleam_core::{ build::{Codegen, Compile, Mode, Options, Package, Target}, config::{DocsPage, PackageConfig}, docs::{Dependency, DependencyKind, DocContext}, - error::{Error, FileIoAction, FileKind}, + error::Error, hex, io::HttpClient as _, manifest::ManifestPackageSource, @@ -151,7 +151,12 @@ pub(crate) fn build_documentation( ) -> Result<Vec<gleam_core::io::OutputFile>, Error> { compiled.attach_doc_and_module_comments(); cli::print_generating_documentation(); - let pages = documentation_pages(paths, config)?; + let mut pages = vec![DocsPage { + title: "README".into(), + path: "index.html".into(), + source: paths.readme(), // TODO: support non markdown READMEs. Or a default if there is none. + }]; + pages.extend(config.documentation.pages.iter().cloned()); let mut outputs = gleam_core::docs::generate_html( paths, gleam_core::docs::DocumentationConfig { @@ -173,218 +178,6 @@ pub(crate) fn build_documentation( Ok(outputs) } -fn documentation_pages( - paths: &ProjectPaths, - config: &PackageConfig, -) -> Result<Vec<DocsPage>, Error> { - let mut pages = vec![DocsPage { - title: "README".into(), - path: "index.html".into(), - source: paths.readme(), // TODO: support non markdown READMEs. Or a default if there is none. - }]; - - for page in &config.documentation.pages { - let path = validate_docs_page_path(paths, config, page)?; - let source = validate_docs_page_source(paths, page)?; - pages.push(DocsPage { - title: page.title.clone(), - path: path.into_string(), - source, - }); - } - - Ok(pages) -} - -fn validate_docs_page_path( - paths: &ProjectPaths, - config: &PackageConfig, - page: &DocsPage, -) -> Result<Utf8PathBuf, Error> { - normalize_path_within_base( - &paths.root_config(), - "path", - Utf8Path::new(&page.path), - &page.path, - &paths.build_documentation_directory(&config.name), - "documentation output directory", - ) -} - -fn validate_docs_page_source(paths: &ProjectPaths, page: &DocsPage) -> Result<Utf8PathBuf, Error> { - let source = normalize_path_within_base( - &paths.root_config(), - "source", - &page.source, - &page.source, - paths.root(), - "project root", - )?; - Ok(paths.root().join(source)) -} - -fn normalize_path_within_base( - config_path: &Utf8Path, - field_name: &str, - path: &Utf8Path, - value: impl std::fmt::Display, - base: &Utf8Path, - boundary_name: &str, -) -> Result<Utf8PathBuf, Error> { - debug_assert!(base.is_absolute()); - let base_parts = base - .components() - .filter_map(|component| match component { - Utf8Component::Normal(component) => Some(component.to_string()), - _ => None, - }) - .collect::<Vec<_>>(); - let mut normalized_parts = base_parts.clone(); - let base_depth = base_parts.len(); - - for component in path.components() { - match component { - Utf8Component::CurDir => (), - Utf8Component::Normal(component) => normalized_parts.push(component.to_string()), - Utf8Component::ParentDir => { - if !normalized_parts.is_empty() { - let _ = normalized_parts.pop(); - } - } - Utf8Component::RootDir | Utf8Component::Prefix(_) => { - return Err(Error::FileIo { - action: FileIoAction::Parse, - kind: FileKind::File, - path: config_path.to_path_buf(), - err: Some(format!( - "Invalid documentation page {field_name} `{value}`. It must stay within the {boundary_name}." - )), - }); - } - } - } - - if normalized_parts.get(..base_depth) != Some(base_parts.as_slice()) { - return Err(Error::FileIo { - action: FileIoAction::Parse, - kind: FileKind::File, - path: config_path.to_path_buf(), - err: Some(format!( - "Invalid documentation page {field_name} `{value}`. It must stay within the {boundary_name}." - )), - }); - } - - let mut normalized = Utf8PathBuf::new(); - for component in normalized_parts.into_iter().skip(base_depth) { - normalized.push(component); - } - - Ok(normalized) -} - -#[cfg(test)] -mod tests { - use super::*; - use gleam_core::config::PackageConfig; - - #[test] - fn custom_docs_page_path_must_stay_within_project() { - let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); - let mut config = PackageConfig::default(); - config.documentation.pages.push(DocsPage { - title: "Escape".into(), - path: "../../escape.html".into(), - source: Utf8PathBuf::from("README.md"), - }); - - let error = documentation_pages(&paths, &config).expect_err("invalid docs page path"); - - assert!(matches!( - error, - Error::FileIo { - action: FileIoAction::Parse, - .. - } - )); - } - - #[test] - fn custom_docs_page_absolute_path_is_rejected() { - let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); - let mut config = PackageConfig::default(); - config.name = "project".into(); - config.documentation.pages.push(DocsPage { - title: "Escape".into(), - path: "/tmp/escape.html".into(), - source: Utf8PathBuf::from("README.md"), - }); - - let error = documentation_pages(&paths, &config).expect_err("invalid docs page path"); - - assert!(matches!( - error, - Error::FileIo { - action: FileIoAction::Parse, - .. - } - )); - } - - #[test] - fn custom_docs_pages_are_resolved_under_the_docs_output_directory() { - let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); - let mut config = PackageConfig::default(); - config.name = "project".into(); - config.documentation.pages.push(DocsPage { - title: "Guide".into(), - path: "../project/guides/intro.html".into(), - source: Utf8PathBuf::from("docs/intro.md"), - }); - - let pages = documentation_pages(&paths, &config).expect("valid docs pages"); - - assert_eq!(pages[1].path, "guides/intro.html"); - assert_eq!(pages[1].source, Utf8PathBuf::from("/tmp/project/docs/intro.md")); - } - - #[test] - fn custom_docs_page_source_must_stay_within_project_root() { - let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); - let mut config = PackageConfig::default(); - config.documentation.pages.push(DocsPage { - title: "Leak".into(), - path: "leak.html".into(), - source: Utf8PathBuf::from("/etc/passwd"), - }); - - let error = documentation_pages(&paths, &config).expect_err("invalid docs page source"); - - assert!(matches!( - error, - Error::FileIo { - action: FileIoAction::Parse, - .. - } - )); - } - - #[test] - fn custom_docs_page_source_can_leave_and_return_within_project_root() { - let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); - let mut config = PackageConfig::default(); - config.documentation.pages.push(DocsPage { - title: "Guide".into(), - path: "guide.html".into(), - source: Utf8PathBuf::from("docs/../README.md"), - }); - - let pages = documentation_pages(&paths, &config).expect("valid docs pages"); - - assert_eq!(pages[1].source, Utf8PathBuf::from("/tmp/project/README.md")); - } -} - pub fn publish(paths: &ProjectPaths) -> Result<()> { let config = crate::config::root_config(paths)?; let http = HttpClient::new();
compiler-core/src/config.rs+183 −0 modified@@ -1015,10 +1015,48 @@ pub struct Docs { #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] pub struct DocsPage { pub title: String, + #[serde(default, deserialize_with = "non_ascending_path_string::deserialize")] pub path: String, + #[serde(default, deserialize_with = "non_ascending_path_buf::deserialize")] pub source: Utf8PathBuf, } +mod non_ascending_path_string { + use serde::{Deserialize, Deserializer, de::Error as _}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result<String, D::Error> + where + D: Deserializer<'de>, + { + let path = String::deserialize(deserializer)?.replace('\\', "/"); + if path.starts_with("../") || path.contains("/../") { + return Err(D::Error::custom("paths must not contain .. segments")); + } + Ok(path) + } +} + +mod non_ascending_path_buf { + use camino::{Utf8Component, Utf8PathBuf}; + use serde::{Deserialize, Deserializer, de::Error as _}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result<Utf8PathBuf, D::Error> + where + D: Deserializer<'de>, + { + let path = Utf8PathBuf::deserialize(deserializer)?; + if path.is_absolute() { + return Err(D::Error::custom("paths must be relative")); + } + for component in path.components() { + if component == Utf8Component::ParentDir { + return Err(D::Error::custom("paths must not contain .. segments")); + } + } + Ok(path) + } +} + #[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)] pub struct Link { pub title: String, @@ -1243,6 +1281,151 @@ name = "1" ) } +#[test] +fn docs_dot_dot_string_path() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "../secrets.txt", source = "./path/to/my-page.md" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[test] +fn docs_prefix_dot_dot_string_path() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "something/../../secrets.txt", source = "./path/to/my-page.md" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[test] +fn docs_windows_dot_dot_string_path() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "..\\secrets.txt", source = "./path/to/my-page.md" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[test] +fn docs_prefix_windows_dot_dot_string_path() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "something\\..\\..\\secrets.txt", source = "./path/to/my-page.md" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[test] +fn docs_dot_dot_path_buf() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "stuff.html", source = "../secrets.txt" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[test] +fn docs_prefix_dot_dot_path_buf() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "stuff.html", source = "something/../../secrets.txt" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + +#[cfg(windows)] +#[test] +fn docs_windows_dot_dot_path_buf() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "stuff.html", source = "..\\secrets.txt" }] +"#; + + assert!(toml::from_str::<PackageConfig>(input).is_err()) +} + +#[cfg(windows)] +#[test] +fn docs_prefix_windows_dot_dot_path_buf() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "stuff.html", source = "something\\..\\..\\secrets.txt" }] +"#; + + assert!(toml::from_str::<PackageConfig>(input).is_err()) +} + +#[test] +fn docs_absolute_source() { + let input = r#" +name = "one_two" + +[documentation] +pages = [{ title = "My Page", path = "stuff.html", source = "/etc/passwd" }] +"#; + + insta::assert_snapshot!( + insta::internals::AutoName, + toml::from_str::<PackageConfig>(input) + .unwrap_err() + .to_string() + ); +} + #[test] fn package_config_to_json() { let input = r#"
compiler-core/src/snapshots/gleam_core__config__docs_absolute_source.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 61 + | +5 | pages = [{ title = "My Page", path = "stuff.html", source = "/etc/passwd" }] + | ^^^^^^^^^^^^^ +paths must be relative
compiler-core/src/snapshots/gleam_core__config__docs_dot_dot_path_buf.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 61 + | +5 | pages = [{ title = "My Page", path = "stuff.html", source = "../secrets.txt" }] + | ^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_dot_dot_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "../secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_dot_dot_string_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "../secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_prefix_dot_dot_path_buf.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 61 + | +5 | pages = [{ title = "My Page", path = "stuff.html", source = "something/../../secrets.txt" }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_prefix_dot_dot_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "something/../../secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_prefix_dot_dot_string_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "something/../../secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_prefix_windows_dot_dot_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "something\\..\\..\\secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_prefix_windows_dot_dot_string_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "something\\..\\..\\secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_windows_dot_dot_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "..\\secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
compiler-core/src/snapshots/gleam_core__config__docs_windows_dot_dot_string_path.snap+9 −0 added@@ -0,0 +1,9 @@ +--- +source: compiler-core/src/config.rs +expression: "toml::from_str::<PackageConfig>(input).unwrap_err().to_string()" +--- +TOML parse error at line 5, column 38 + | +5 | pages = [{ title = "My Page", path = "..\\secrets.txt", source = "./path/to/my-page.md" }] + | ^^^^^^^^^^^^^^^^^ +paths must not contain .. segments
c9230cd3045dRestrict documentation page path handling
2 files changed · +219 −8
CHANGELOG.md+4 −0 modified@@ -511,3 +511,7 @@ would not be offered when the cursor was on an inexhaustive `case` expression if another inexhaustive `case` appeared earlier in the same module. ([John Downey](https://github.com/jtdowney)) + +- Restrict custom documentation page `path` and `source` values so `gleam docs + build` cannot escape the docs output directory or project root. + ([evipepota](https://github.com/evipepota))
compiler-cli/src/docs.rs+215 −8 modified@@ -1,6 +1,6 @@ use std::{collections::HashMap, time::SystemTime}; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; use ecow::EcoString; use crate::{cli, fs::ProjectIO, http::HttpClient}; @@ -10,7 +10,7 @@ use gleam_core::{ build::{Codegen, Compile, Mode, Options, Package, Target}, config::{DocsPage, PackageConfig}, docs::{Dependency, DependencyKind, DocContext}, - error::Error, + error::{Error, FileIoAction, FileKind}, hex, io::HttpClient as _, manifest::ManifestPackageSource, @@ -151,12 +151,7 @@ pub(crate) fn build_documentation( ) -> Result<Vec<gleam_core::io::OutputFile>, Error> { compiled.attach_doc_and_module_comments(); cli::print_generating_documentation(); - let mut pages = vec![DocsPage { - title: "README".into(), - path: "index.html".into(), - source: paths.readme(), // TODO: support non markdown READMEs. Or a default if there is none. - }]; - pages.extend(config.documentation.pages.iter().cloned()); + let pages = documentation_pages(paths, config)?; let mut outputs = gleam_core::docs::generate_html( paths, gleam_core::docs::DocumentationConfig { @@ -178,6 +173,218 @@ pub(crate) fn build_documentation( Ok(outputs) } +fn documentation_pages( + paths: &ProjectPaths, + config: &PackageConfig, +) -> Result<Vec<DocsPage>, Error> { + let mut pages = vec![DocsPage { + title: "README".into(), + path: "index.html".into(), + source: paths.readme(), // TODO: support non markdown READMEs. Or a default if there is none. + }]; + + for page in &config.documentation.pages { + let path = validate_docs_page_path(paths, config, page)?; + let source = validate_docs_page_source(paths, page)?; + pages.push(DocsPage { + title: page.title.clone(), + path: path.into_string(), + source, + }); + } + + Ok(pages) +} + +fn validate_docs_page_path( + paths: &ProjectPaths, + config: &PackageConfig, + page: &DocsPage, +) -> Result<Utf8PathBuf, Error> { + normalize_path_within_base( + &paths.root_config(), + "path", + Utf8Path::new(&page.path), + &page.path, + &paths.build_documentation_directory(&config.name), + "documentation output directory", + ) +} + +fn validate_docs_page_source(paths: &ProjectPaths, page: &DocsPage) -> Result<Utf8PathBuf, Error> { + let source = normalize_path_within_base( + &paths.root_config(), + "source", + &page.source, + &page.source, + paths.root(), + "project root", + )?; + Ok(paths.root().join(source)) +} + +fn normalize_path_within_base( + config_path: &Utf8Path, + field_name: &str, + path: &Utf8Path, + value: impl std::fmt::Display, + base: &Utf8Path, + boundary_name: &str, +) -> Result<Utf8PathBuf, Error> { + debug_assert!(base.is_absolute()); + let base_parts = base + .components() + .filter_map(|component| match component { + Utf8Component::Normal(component) => Some(component.to_string()), + _ => None, + }) + .collect::<Vec<_>>(); + let mut normalized_parts = base_parts.clone(); + let base_depth = base_parts.len(); + + for component in path.components() { + match component { + Utf8Component::CurDir => (), + Utf8Component::Normal(component) => normalized_parts.push(component.to_string()), + Utf8Component::ParentDir => { + if !normalized_parts.is_empty() { + let _ = normalized_parts.pop(); + } + } + Utf8Component::RootDir | Utf8Component::Prefix(_) => { + return Err(Error::FileIo { + action: FileIoAction::Parse, + kind: FileKind::File, + path: config_path.to_path_buf(), + err: Some(format!( + "Invalid documentation page {field_name} `{value}`. It must stay within the {boundary_name}." + )), + }); + } + } + } + + if normalized_parts.get(..base_depth) != Some(base_parts.as_slice()) { + return Err(Error::FileIo { + action: FileIoAction::Parse, + kind: FileKind::File, + path: config_path.to_path_buf(), + err: Some(format!( + "Invalid documentation page {field_name} `{value}`. It must stay within the {boundary_name}." + )), + }); + } + + let mut normalized = Utf8PathBuf::new(); + for component in normalized_parts.into_iter().skip(base_depth) { + normalized.push(component); + } + + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + use gleam_core::config::PackageConfig; + + #[test] + fn custom_docs_page_path_must_stay_within_project() { + let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); + let mut config = PackageConfig::default(); + config.documentation.pages.push(DocsPage { + title: "Escape".into(), + path: "../../escape.html".into(), + source: Utf8PathBuf::from("README.md"), + }); + + let error = documentation_pages(&paths, &config).expect_err("invalid docs page path"); + + assert!(matches!( + error, + Error::FileIo { + action: FileIoAction::Parse, + .. + } + )); + } + + #[test] + fn custom_docs_page_absolute_path_is_rejected() { + let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); + let mut config = PackageConfig::default(); + config.name = "project".into(); + config.documentation.pages.push(DocsPage { + title: "Escape".into(), + path: "/tmp/escape.html".into(), + source: Utf8PathBuf::from("README.md"), + }); + + let error = documentation_pages(&paths, &config).expect_err("invalid docs page path"); + + assert!(matches!( + error, + Error::FileIo { + action: FileIoAction::Parse, + .. + } + )); + } + + #[test] + fn custom_docs_pages_are_resolved_under_the_docs_output_directory() { + let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); + let mut config = PackageConfig::default(); + config.name = "project".into(); + config.documentation.pages.push(DocsPage { + title: "Guide".into(), + path: "../project/guides/intro.html".into(), + source: Utf8PathBuf::from("docs/intro.md"), + }); + + let pages = documentation_pages(&paths, &config).expect("valid docs pages"); + + assert_eq!(pages[1].path, "guides/intro.html"); + assert_eq!(pages[1].source, Utf8PathBuf::from("/tmp/project/docs/intro.md")); + } + + #[test] + fn custom_docs_page_source_must_stay_within_project_root() { + let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); + let mut config = PackageConfig::default(); + config.documentation.pages.push(DocsPage { + title: "Leak".into(), + path: "leak.html".into(), + source: Utf8PathBuf::from("/etc/passwd"), + }); + + let error = documentation_pages(&paths, &config).expect_err("invalid docs page source"); + + assert!(matches!( + error, + Error::FileIo { + action: FileIoAction::Parse, + .. + } + )); + } + + #[test] + fn custom_docs_page_source_can_leave_and_return_within_project_root() { + let paths = ProjectPaths::new(Utf8PathBuf::from("/tmp/project")); + let mut config = PackageConfig::default(); + config.documentation.pages.push(DocsPage { + title: "Guide".into(), + path: "guide.html".into(), + source: Utf8PathBuf::from("docs/../README.md"), + }); + + let pages = documentation_pages(&paths, &config).expect("valid docs pages"); + + assert_eq!(pages[1].source, Utf8PathBuf::from("/tmp/project/README.md")); + } +} + pub fn publish(paths: &ProjectPaths) -> Result<()> { let config = crate::config::root_config(paths)?; let http = HttpClient::new();
Vulnerability mechanics
Root cause
"The `documentation.pages` entries in `gleam.toml` were not sufficiently validated, allowing paths to escape the intended directories."
Attack vector
An attacker can convince a victim to run `gleam docs build` on a malicious project. By crafting specific `documentation.pages` entries in the project's `gleam.toml`, the attacker can cause arbitrary files readable by the victim to be included in the generated documentation. Additionally, the `path` field can be manipulated to write generated files outside the intended output directory.
Affected code
The vulnerability lies in the handling of custom documentation pages defined in `gleam.toml`. Specifically, the `documentation.pages` entries, including their `path` and `source` fields, were processed without adequate validation in `compiler-cli/src/docs.rs` and `compiler-core/src/config.rs`.
What the fix does
The patch introduces validation for `documentation.pages` entries during deserialization of the `gleam.toml` configuration. New deserializers (`non_ascending_path_string` and `non_ascending_path_buf`) are added to `compiler-core/src/config.rs` to reject paths containing `..` segments or absolute paths. This prevents the `path` and `source` fields from escaping their intended directories, thereby mitigating the path traversal vulnerability [patch_id=4494239]. The `build_documentation` function in `compiler-cli/src/docs.rs` was also refactored to use these new validation mechanisms [patch_id=4494238].
Preconditions
- inputThe attacker must provide a `gleam.toml` file with malicious `documentation.pages` entries.
- inputA victim must run `gleam docs build` on a project containing the malicious `gleam.toml`.
Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- cna.erlef.org/cves/CVE-2026-32685.htmlnvd
- github.com/gleam-lang/gleam/commit/81570611906b6b0039c948037094d09a68700f3anvd
- github.com/gleam-lang/gleam/commit/c9230cd3045de8fd8481dae3a4557c0146df1430nvd
- github.com/gleam-lang/gleam/security/advisories/GHSA-wjx8-7w8m-p4v7nvd
- osv.dev/vulnerability/EEF-CVE-2026-32685nvd
News mentions
0No linked articles in our index yet.