VYPR
Medium severity4.4GHSA Advisory· Published May 21, 2026

Rust OneNote File Parser: Path traversal in `Parser::parse_notebook` allows reading files outside the notebook directory

CVE-2026-46671

Description

Impact

A maliciously crafted .onetoc2 table-of-contents file can cause Parser::parse_notebook to open arbitrary files on the host filesystem outside the notebook's directory. The parser reads entry names listed inside the .onetoc2 and joins them against the notebook's base directory without validating that they are relative paths confined to that directory.

The parser will bail out when the target file fails to parse as a OneNote section, so direct content exfiltration through the parser's return value is not practical, though file-existence probing and denial-of-service via large or special files remain possible.

Anyone using onenote_parser to parse .onetoc2 files received from untrusted sources is affected. Users who only ever parse their own notebooks are not at meaningful risk.

Patches

Fixed in onenote_parser 1.1.1. The fix rejects absolute paths, parent-directory components, and other invalid path characters in entry names, and additionally canonicalises the resolved path to confirm it stays inside the notebook's base directory.

Workarounds

For users who cannot upgrade to 1.1.1:

  • Only call Parser::parse_notebook on .onetoc2 files from trusted sources.
  • Alternatively, use Parser::parse_section / Parser::parse_section_buffer on individual .one files, which do not perform the directory walk.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

A path traversal vulnerability in onenote_parser allows arbitrary file reads via a malicious .onetoc2 file, fixed in version 1.1.1.

Vulnerability

Overview

CVE-2026-46671 is a path traversal vulnerability in the onenote_parser library, specifically in the Parser::parse_notebook function. The root cause is that the parser reads entry names from a .onetoc2 table-of-contents file and joins them against the notebook's base directory without validating that the resulting path stays within that directory. This allows an attacker to craft a malicious .onetoc2 file containing absolute paths or parent-directory components (..), causing the parser to open arbitrary files on the host filesystem [1][2][4].

Exploitation

An attacker can exploit this by supplying a specially crafted .onetoc2 file to a user or application that calls Parser::parse_notebook on untrusted input. The parser will attempt to open the files referenced in the entry names, regardless of their location. No authentication is required beyond the ability to provide the malicious file. The attack surface is limited to users who parse .onetoc2 files from untrusted sources; those who only parse their own notebooks are not at risk [4].

Impact

While the parser will bail out if the target file does not parse as a valid OneNote section, preventing direct content exfiltration through the parser's return value, an attacker can still probe for file existence and cause denial-of-service by referencing large or special files (e.g., /dev/random). This makes the vulnerability useful for information gathering and disruption [4].

Mitigation

The vulnerability is fixed in onenote_parser version 1.1.1. The patch rejects absolute paths, parent-directory components, and invalid path characters in entry names, and additionally canonicalizes the resolved path to confirm it stays inside the notebook's base directory [1][2][3]. For users who cannot upgrade, workarounds include only calling Parser::parse_notebook on trusted .onetoc2 files, or using Parser::parse_section / Parser::parse_section_buffer on individual .one files, which do not perform the directory walk [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
c9267b2c96e2

fix: prevent path traversal while parsing onetoc2 file

https://github.com/msiemens/onenote.rsMarkus SiemensJan 9, 2026via ghsa
2 files changed · +110 8
  • Cargo.toml+2 0 modified
    @@ -21,9 +21,11 @@ enum-primitive-derive = "0.3"
     itertools = "0.14"
     num-traits = "0.2"
     pastey = "0.2"
    +sanitise-file-name = "1.0"
     thiserror = "2.0"
     uuid = "1.19"
     widestring = "1.2"
     
     [dev-dependencies]
     insta = "1.45"
    +tempfile = "3.15"
    
  • src/onenote/mod.rs+108 8 modified
    @@ -4,10 +4,11 @@ use crate::onenote::notebook::Notebook;
     use crate::onenote::section::{Section, SectionEntry, SectionGroup};
     use crate::onestore::parse_store;
     use crate::reader::Reader;
    +use sanitise_file_name::sanitise;
     use std::ffi::OsStr;
     use std::fs::File;
     use std::io::{BufReader, Read};
    -use std::path::Path;
    +use std::path::{Component, Path, PathBuf};
     
     pub(crate) mod content;
     pub(crate) mod embedded_file;
    @@ -72,14 +73,12 @@ impl Parser {
                 message: "path has no parent directory".into(),
             })?;
             let (entries, color) = notebook::parse_toc(store.data_root())?;
    -        let sections = entries
    +        let entries = entries
                 .iter()
    -            .map(|name| {
    -                let mut file = base_dir.to_path_buf();
    -                file.push(name);
    -
    -                file
    -            })
    +            .map(|name| resolve_entry_path(base_dir, name))
    +            .collect::<Result<Vec<_>>>()?;
    +        let sections = entries
    +            .into_iter()
                 .filter(|p| p.exists())
                 .filter(|p| !p.ends_with("OneNote_RecycleBin"))
                 .map(|path| {
    @@ -194,8 +193,109 @@ impl Parser {
         }
     }
     
    +fn resolve_entry_path(base_dir: &Path, entry: &str) -> Result<PathBuf> {
    +    let entry_path = Path::new(entry);
    +    if entry_path.is_absolute() {
    +        return Err(ErrorKind::InvalidPath {
    +            message: "section entry must be a relative path".into(),
    +        }
    +        .into());
    +    }
    +
    +    let mut sanitized = PathBuf::new();
    +    for component in entry_path.components() {
    +        match component {
    +            Component::Normal(name) => {
    +                let name = name.to_str().ok_or_else(|| ErrorKind::InvalidPath {
    +                    message: "section entry contains non-utf8 characters".into(),
    +                })?;
    +                let clean = sanitise(name);
    +                if clean != name {
    +                    return Err(ErrorKind::InvalidPath {
    +                        message: format!("section entry contains invalid characters: {name}").into(),
    +                    }
    +                    .into());
    +                }
    +                sanitized.push(name);
    +            }
    +            Component::CurDir => {}
    +            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
    +                return Err(ErrorKind::InvalidPath {
    +                    message: "section entry contains invalid path components".into(),
    +                }
    +                .into());
    +            }
    +        }
    +    }
    +
    +    if sanitized.as_os_str().is_empty() {
    +        return Err(ErrorKind::InvalidPath {
    +            message: "section entry is empty".into(),
    +        }
    +        .into());
    +    }
    +
    +    let candidate = base_dir.join(&sanitized);
    +    if candidate.exists() {
    +        let base_canon = base_dir.canonicalize().map_err(|err| ErrorKind::InvalidPath {
    +            message: format!("failed to resolve base directory: {err}").into(),
    +        })?;
    +        let candidate_canon =
    +            candidate
    +                .canonicalize()
    +                .map_err(|err| ErrorKind::InvalidPath {
    +                    message: format!("failed to resolve entry path: {err}").into(),
    +                })?;
    +        if !candidate_canon.starts_with(&base_canon) {
    +            return Err(ErrorKind::InvalidPath {
    +                message: "section entry escapes base directory".into(),
    +            }
    +            .into());
    +        }
    +    }
    +
    +    Ok(candidate)
    +}
    +
     impl Default for Parser {
         fn default() -> Self {
             Self::new()
         }
     }
    +
    +#[cfg(test)]
    +mod tests {
    +    use super::resolve_entry_path;
    +    use std::path::Path;
    +    use tempfile::tempdir;
    +
    +    #[test]
    +    fn test_resolve_entry_path_rejects_traversal() {
    +        let dir = tempdir().unwrap();
    +        let base = dir.path();
    +
    +        let err = resolve_entry_path(base, "../secret.one").unwrap_err();
    +        let err = format!("{err}");
    +        assert!(err.contains("invalid path components"));
    +    }
    +
    +    #[test]
    +    fn test_resolve_entry_path_rejects_absolute() {
    +        let dir = tempdir().unwrap();
    +        let base = dir.path();
    +
    +        let candidate = if cfg!(windows) { r"C:\secret.one" } else { "/etc/passwd" };
    +        let err = resolve_entry_path(base, candidate).unwrap_err();
    +        let err = format!("{err}");
    +        assert!(err.contains("relative path"));
    +    }
    +
    +    #[test]
    +    fn test_resolve_entry_path_accepts_relative() {
    +        let dir = tempdir().unwrap();
    +        let base = dir.path();
    +
    +        let resolved = resolve_entry_path(base, "Section 1.one").unwrap();
    +        assert_eq!(resolved, Path::new(base).join("Section 1.one"));
    +    }
    +}
    

Vulnerability mechanics

Root cause

"Missing path validation in `Parser::parse_notebook` allows directory traversal via `.onetoc2` entry names."

Attack vector

An attacker crafts a malicious `.onetoc2` file containing entry names with parent-directory components (e.g., `../secret.one`) or absolute paths. When `Parser::parse_notebook` is called on this file, the old code joins each entry name directly against the notebook's base directory without sanitisation [patch_id=1264021]. The parser then attempts to open the resolved path; if the target exists and is a valid OneNote section it will be parsed, otherwise the parser bails out. This enables file-existence probing and denial-of-service against arbitrary files on the host filesystem.

Affected code

The vulnerable code is in `src/onenote/mod.rs` within `Parser::parse_notebook`, which iterates over entry names from a parsed `.onetoc2` file and joins them onto `base_dir` without validation. The fix adds the `resolve_entry_path` function in the same file and replaces the naive `file.push(name)` loop with a call to this new function [patch_id=1264021].

What the fix does

The patch introduces `resolve_entry_path` which validates each component of the entry name [patch_id=1264021]. It rejects absolute paths, `ParentDir` (`..`), `RootDir`, and `Prefix` components, and strips `CurDir` (`.`) components. It also sanitises normal components using the `sanitise-file-name` crate. When the resolved candidate already exists on disk, the function canonicalises both the base directory and the candidate path and checks that the candidate starts with the base directory, providing a defence-in-depth check against symlink-based escapes [patch_id=1264021].

Preconditions

  • inputThe victim must call Parser::parse_notebook on a .onetoc2 file from an untrusted source.
  • inputThe attacker must control the contents of the .onetoc2 file, specifically the entry names listed inside it.

Generated on May 21, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.