{"id":"PYSEC-2026-2999","summary":"Regression in pymdownx.snippets reintroduces sibling-prefix path traversal bypass despite restrict_base_path","details":"# Summary\n\n`pymdownx.snippets` has a regression of the CVE-2023-32309 / GHSA-jh85-wwv9-24hv fix. With `restrict_base_path: True` (the default), the current `filename.startswith(base)` containment check does not enforce a directory boundary. As a result, a markdown snippet directive can read files from sibling paths that share the same prefix as `base_path`, such as `docs` vs `docs_internal`.\n\nThe regression was introduced in PR #2039 / commit `7c13bda5b7793b172efd1abb6712e156a83fe07d`, which replaced the original directory-identity check with a plain string-prefix comparison.\n\n# Details\n\nThe regression was introduced in commit `7c13bda5b7793b172efd1abb6712e156a83fe07d` (2023-05-15, #2039 *\"Fix regression of snippets nested deeply under specified base path\"*), which relaxed the original `os.path.samefile(base, os.path.dirname(filename))` check to a plain `startswith(base)`.\n\n`SnippetPreprocessor.get_snippet_path()` in `pymdownx/snippets.py`:\n\n```python\nif self.restrict_base_path:\n    filename = os.path.abspath(os.path.join(base, path))\n    # If the absolute path is no longer under the specified base path, reject the file\n    if not filename.startswith(base):\n        continue\n```\n\n`base` is `os.path.abspath(b)` and has no trailing separator. `str.startswith(base)` is `True` for any `filename` whose string representation begins with the same characters as `base`, regardless of whether those characters end at a directory boundary.\n\nConcrete example:\n\n* `base = \"/x/docs\"`\n* `path = \"../docs_secret/leak.txt\"` (inside the markdown snippet directive)\n* `os.path.join(base, path)` → `\"/x/docs/../docs_secret/leak.txt\"`\n* `os.path.abspath(...)`     → `\"/x/docs_secret/leak.txt\"`\n* `filename.startswith(base)` → `True`, because `\"/x/docs_secret/...\"` begins with the literal string `\"/x/docs\"`.\n\nAll releases from **10.0.1 (2023-05-15) through 10.21.2 (current)** are affected.\n\n# Impact\n\nArbitrary file read within the host the build runs on, bounded by the prefix match. With `base_path = /x/docs` the attacker can read files from any sibling directory whose path begins with the literal string `/x/docs` followed by any non-separator character — for example `/x/docs_internal/`, `/x/docs.bak/`, `/x/docs2/`.\n\nThe threat model is the same as the original CVE-2023-32309: markdown content processed by the snippets preprocessor in a build pipeline (typical scenario: an MkDocs documentation site built in CI from PR contributions or otherwise less-trusted markdown) can read files outside the configured base. CI builds that publish the generated HTML expose the read file to the public; CI builds with secrets on disk leak those secrets.\n\n# Reproduction\n\nMinimal local PoC, non-destructive:\n\n```python\nimport os, shutil, tempfile, markdown\n\nwork = tempfile.mkdtemp(prefix=\"pmx_poc_\")\ntry:\n    base    = os.path.join(work, \"docs\")\n    sibling = os.path.join(work, \"docs_secret\")\n    os.makedirs(base)\n    os.makedirs(sibling)\n    with open(os.path.join(sibling, \"leak.txt\"), \"w\") as f:\n        f.write(\"TOP_SECRET_FROM_SIBLING_DIR\\n\")\n\n    out = markdown.markdown(\n        '--8\u003c-- \"../docs_secret/leak.txt\"\\n',\n        extensions=[\"pymdownx.snippets\"],\n        extension_configs={\n            \"pymdownx.snippets\": {\n                \"base_path\": [base],\n                \"restrict_base_path\": True,\n                \"check_paths\": True,\n            }\n        },\n    )\n    print(out)  # -\u003e \u003cp\u003eTOP_SECRET_FROM_SIBLING_DIR\u003c/p\u003e\nfinally:\n    shutil.rmtree(work)\n```\n\nDefault `restrict_base_path: True` is sufficient — no non-default option is required.\n\n# Suggested fix\n\nMinimal change — require the separator after the base prefix:\n\n```diff\n-                        if not filename.startswith(base):\n+                        # Append `os.sep` so a sibling directory whose name shares a prefix\n+                        # (e.g. `/x/docs` vs `/x/docs_evil`) cannot satisfy the check.\n+                        if not filename.startswith(base + os.sep):\n                             continue\n```\n\nThis preserves the original intent (allow snippets nested at any depth under `base_path`) while restoring the directory-boundary check. It does not affect the `os.path.isdir(base)` branch where `base` is a file (that branch still uses `os.path.samefile`).\n\nAlternative: `os.path.commonpath([base, filename]) == base` is equivalent and slightly more idiomatic, though it raises `ValueError` on different drives on Windows and would need a `try/except`. The `startswith(base + os.sep)` fix is the smaller diff.\n\nNote: this fix does not change behaviour for symlinks inside `base_path`. The existing implementation uses `os.path.abspath` (not `os.path.realpath`), so a symlink within `base_path` pointing outside is still followed. That is a separate concern — symlinks require write access to `base_path`, a much higher bar than the current bypass — and matches the behaviour the CVE-2023 fix established.\n\n# Regression test\n\nA regression test class `TestSnippetsSiblingPrefix` was added in `tests/test_extensions/test_snippets.py`. It uses `tests/test_extensions/_snippets/nested` as `base_path` and a new fixture directory `tests/test_extensions/_snippets/nested_sibling_evil/leak.txt`. It asserts that the markdown directive `--8\u003c-- \"../nested_sibling_evil/leak.txt\"` raises `SnippetMissingError`.\n\n* Without fix: test fails (`AssertionError: SnippetMissingError not raised`, sibling file is silently read).\n* With fix: test passes.\n\nFull suite: `python -m pytest tests/ -q` → **738 passed** (737 baseline + 1 new regression test). No regressions.\n\n# Affected versions\n\n`\u003e= 10.0.1, \u003c= 10.21.2`","aliases":["CVE-2026-46338","GHSA-62q4-447f-wv8h"],"modified":"2026-07-13T16:33:07.759730233Z","published":"2026-07-13T15:19:09.231055Z","references":[{"type":"WEB","url":"https://github.com/facelessuser/pymdown-extensions/security/advisories/GHSA-62q4-447f-wv8h"},{"type":"WEB","url":"https://github.com/facelessuser/pymdown-extensions/pull/2039"},{"type":"PACKAGE","url":"https://github.com/facelessuser/pymdown-extensions"},{"type":"PACKAGE","url":"https://pypi.org/project/pymdown-extensions"},{"type":"ADVISORY","url":"https://github.com/advisories/GHSA-62q4-447f-wv8h"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-46338"}],"affected":[{"package":{"name":"pymdown-extensions","ecosystem":"PyPI","purl":"pkg:pypi/pymdown-extensions"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"10.0.1"},{"fixed":"10.21.3"}]}],"versions":["10.0.1","10.1","10.10","10.10.1","10.10.2","10.11","10.11.1","10.11.2","10.12","10.13","10.14","10.14.1","10.14.2","10.14.3","10.15","10.16","10.16.1","10.17","10.17.1","10.17.2","10.18","10.19","10.19.1","10.2","10.2.1","10.20","10.20.1","10.21","10.21.2","10.3","10.3.1","10.4","10.5","10.6","10.7","10.7.1","10.8","10.8.1","10.9"],"database_specific":{"source":"https://github.com/pypa/advisory-database/blob/main/vulns/pymdown-extensions/PYSEC-2026-2999.yaml"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N"}]}