Skip to content

fix(mrml-core): make local loader containment hold - #695

Merged
jdrouet merged 1 commit into
mainfrom
fix/local-loader-containment
Sep 13, 2026
Merged

jdrouet merged 1 commit into
mainfrom
fix/local-loader-containment

Conversation

@jdrouet

@jdrouet jdrouet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Breaking — for 7.0. Removes LocalIncludeLoader::default().

The type documents that it "doesn't allow to go fetch a template that is in a parent directory of the root directory". That guarantee fails in both directions.

It reads anything. The struct derived Default, giving root == PathBuf::default() == "", and Path::starts_with("") is unconditionally true. So every canonicalized path passes:

LocalIncludeLoader::default().resolve("file:///../../../../../../../../etc/hostname")
-> Ok("<contents of /etc/hostname>")

file:////etc/hostname gets there too — the fourth slash survives trim_start_matches("file:///"), and PathBuf::join with an absolute path discards the root.

It reads nothing. build_path canonicalizes the target but compares it against the root as supplied. Any root that is not already canonical matches nothing, so the loader rejects every input — including legitimate ones. The crate's own documented example is in this state: its root is relative, so it could never load a file. Same for mrml --local-loader ../partials, and for any root reached through a symlinked ancestor.

The containment check itself is right — with an absolute, canonical root I confirmed both traversal payloads are blocked. So this fixes the two ways its precondition gets violated, not the check:

  • The Default derive is gone, so the empty-root state is no longer constructible.
  • The root is canonicalized before comparison, so relative roots, .. and symlinked ancestors all behave. If the root does not exist, that is now an error rather than a silent fallback to the raw path — a fallback would just reinstate the second bug.
  • The doc example is fixed, and the security note now states the guarantee precisely: the resolved file must sit inside the root, with .. and symlinks resolved first.

mrml-cli and mrml-python both build their root absolutely and call new, so neither needed a change.

Three regression tests, each checked failing before and passing after: a relative root loading a legitimate file, and both traversal payloads still refused under a relative root. should_handle_dots_with_existing_file used to pass for the wrong reason — its relative root rejected everything — and now passes because the path genuinely resolves to a sibling of the root.

Tests 621 -> 624.

Signed-off-by: Jeremie Drouet <jeremie.drouet@gmail.com>
@github-actions

Copy link
Copy Markdown
Contributor

📈 This is how your metrics evolved 📉


@jdrouet
jdrouet merged commit 8987c0f into main Sep 13, 2026
18 checks passed
@jdrouet
jdrouet deleted the fix/local-loader-containment branch September 13, 2026 17:55
@github-actions github-actions Bot mentioned this pull request Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant