Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report vulnerabilities privately through GitHub's private vulnerability reporting. This routes the report to the maintainers privately and lets us collaborate on a fix and coordinated disclosure.
Please include, where possible:
- A description of the issue and its impact
- The affected component (CLI,
mds-core, WASM, native addon, a bundler plugin) - Steps to reproduce, or a minimal
.mdstemplate / input that triggers it - The version (crate or npm package) and your platform
We aim to acknowledge reports within a few days and will keep you updated as we investigate.
MDS is pre-1.0. Security fixes are applied to the latest released minor series only; please upgrade to the newest release before reporting.
MDS treats template sources, imported modules, and runtime variables as untrusted input. The compiler enforces several defense-in-depth controls:
- Path-traversal prevention: import paths are rejected if they resolve outside
the project root (
..traversal, or a symlinked parent directory leading out), and anmds.jsonbuild.output_dircontaining any..component is refused (mds::io, exit 2). The project root is the nearest ancestor directory containing a.gitor.mdsrootmarker, found by walking upward from the compiled file's directory. Placing a.mdsrootfile is therefore a security-relevant decision: it sets the containment boundary for all imports, and placing it closer to the input narrows that boundary while placing it farther away widens it. - Symlink rejection: an entry file, import target or
--varsfile whose final path component is a symbolic link (on Windows also a junction) is refused, and so is a base directory passed toModuleCache::resolve_source*. The check reads the file type of the final component itself (not following it), then requires its canonical path to lie in the canonical parent directory. Symbolic links in parent directories are followed and the result is then subject to containment. The CLI checks the directory argument ofmds build,mds check,mds fmt,mds lintandmds watchby the same rule before it is walked, however it is typed (link/andlink/.included), and refuses a symlinked one, or the filesystem root, asmds::io, exit 2 (#413).mds watchre-checks the path it watches before every rebuild — the entry in file mode, the directory argument and each source below it in directory mode — and refuses the rebuild once a symbolic link on that path has been retargeted (#417, #413). - Null-byte rejection: paths containing NUL bytes are rejected at the API boundary rather than being passed to the OS.
- Forbidden path characters are refused at input, not only escaped on output
(#265): a path carrying any of the 80 codepoints of
mds::is_forbidden_path_char— every C0 control including TAB and LF, DEL, every C1 control, and the bidi, line/paragraph-separator and BOM hazards — is refused before the file it names is read: import strings (mds::import); entry paths, virtual entry keys, base directories and resolved canonical paths (mds::io); and on the CLI the-o/--out-dir/build.output_diroutput locations and themds initfilename (mds::io, exit 2).@mdscript/mds's WASM backend applies the same class in its JS pre-scanner before it reads a file. The resolver runs these checks before aFileSystembackend is called, so a custom backend passed toModuleCache::with_fsis covered for every path its caller supplies; a path the backend produces itself (a key it rewrites, a link it follows) is its own responsibility, and its contract requires it to applymds::is_forbidden_path_char. Output escaping stays in place as the second layer: diagnostics escape control, bidi and separator characters (spec §7.5 "Sanitization invariant"), so a hostile name that reaches one is displayed as\uXXXXtext instead of being interpreted by the terminal. - Non-UTF-8 paths are rejected at the public API boundary with an explicit error instead of producing corrupted output. A resolved path that is not valid UTF-8 — reached through a symbolic link into such a directory — is refused too, never turned into a lossy key, which would name a different, unchecked file.
- Path segment cap: an entry path or import path of more than 256 segments is
refused (
mds::resource_limit) on both built-in backends. - Source-map anchors are byte-faithful: the project root and
source_map_baseused to decide whether asources[]entry is inside the project are never derived from a lossy string. A root that is empty or not valid UTF-8 is treated as "no root" — entries degrade to basenames — so it can never make the containment check vacuous. - Replace-by-rename writes:
mds fmt,mds lint --fix,mds init, andmds build/mds watchoutputs and.mapsidecars are written to a same-directory temp file and renamed over the target after a final symlink re-check (mds-cli/src/output.rs,atomic_write_file; enforced bycrates/mds-cli/tests/write_funnel.rs), so a crash never leaves a truncated target and a symlinked output path is refused.mds buildandmds watchrefuse an output that is the entry file itself — however-o,--out-dir,build.output_diror the default output name it — before anything is written or any directory created (mds::io, exit 2, #425). Consequence: hard links, ACLs, xattrs, and owner/group of a pre-existing target are not preserved (permission bits are, on Unix) — see spec §7.2 "Output writing".
The symlink, containment, NUL-byte, forbidden-character, path-encoding and
segment-count rules above are specified normatively — with their error codes, the
place each check runs, and the tests that pin them — in spec.md §4.6
"Filesystem constraints"; this section is the overview.
| Limit | Value | Location |
|---|---|---|
| Max file size | 10 MiB (10,485,760 bytes) per source file (file, virtual module, or in-memory string); a file is never read more than one byte past it — one over it when it is opened is refused unread, and one that grows past it while it is read is read no further (#428) | limits.rs (MAX_FILE_SIZE) |
| Max frontmatter size | 1 MiB per block | limits.rs (MAX_FRONTMATTER_SIZE) |
| Max frontmatter YAML nodes | 200,000 per block (alias expansion counted) | limits.rs (MAX_FRONTMATTER_NODES) |
| Max frontmatter flow-nesting depth | 1024 (checked pre-parse) | limits.rs (MAX_FRONTMATTER_FLOW_DEPTH) |
| YAML parser nesting depth | 128 | serde_yaml_ng (reported as mds::yaml) |
Max mds.json size |
1 MiB (1,048,576 bytes), read no more than one byte past it | mds-cli/src/build.rs (MAX_CONFIG_SIZE) |
| Max call depth | 128 | evaluator.rs (MAX_CALL_DEPTH) |
| Max iterations per loop | 100,000 | evaluator.rs (MAX_LOOP_ITERATIONS) |
| Max total iterations | 1,000,000 | evaluator.rs (MAX_TOTAL_ITERATIONS) |
| Max output size | 50 MiB (52,428,800 bytes) per output buffer, checked before every append; it does not bound a compile's total memory, since nested blocks, function results and imported modules each hold a buffer of their own (#420) | limits.rs (MAX_OUTPUT_SIZE) |
| Max warnings | 1,000 | evaluator.rs (MAX_WARNINGS) |
| Max import depth | 64 | resolver.rs (MAX_IMPORT_DEPTH) |
| Max path segments | 256 per entry or import path | fs.rs (MAX_PATH_SEGMENTS) |
| Max block nesting depth | 64 | limits.rs (MAX_NESTING_DEPTH) |
| Max @elseif branches per @if | 256 | limits.rs (MAX_ELSEIF_BRANCHES) |
| Max value (YAML/JSON) nesting depth | 64 | value.rs (MAX_VALUE_DEPTH) |
| Max dot-path segments | 32 | limits.rs (MAX_DOT_SEGMENTS) |
These guard against adversarial input causing stack overflow, unbounded memory growth, or non-termination.
The three binding crates — mds-napi, mds-wasm and mds-python — declare an
off-by-default debug-panics Cargo feature (crates/mds-napi/Cargo.toml,
crates/mds-wasm/Cargo.toml, crates/mds-python/Cargo.toml). mds-core and
mds-cli have no such feature: the CLI installs no panic hook, so a panic there is
a plain Rust panic (exit code 101) with no error object to attach a payload to. When
enabled, the feature surfaces the raw Rust panic payload as err.detail on
mds::internal errors thrown at the binding boundary, to help diagnose unexpected
panics during local development.
Never enable debug-panics in a published or production build. Panic messages
can contain absolute filesystem paths and other internal details that should not be
exposed to template authors or end users. The feature is off unless opted into
explicitly: none of the three crates lists it in a default feature set
(mds-python's default is extension-module only), and the commands that build the
published artifacts — napi build --release in release.yml for the addon, the
@mdscript/mds-wasm build script (wasm-pack build ../../crates/mds-wasm --target nodejs … and --target web …) for the WASM package, and maturin with
pyproject.toml's features = ["pyo3/abi3-py311"] for the wheels — pass no
--features debug-panics. No automated gate asserts this; it is checked by reading
those three build sites.