diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md index c53e03365..fdf31a3ff 100644 --- a/docs/src/markdown/about/changelog.md +++ b/docs/src/markdown/about/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 10.0 + +- **Break**: Snippets: snippets will restrict snippets to ensure they are under the `base_path` preventing snippets + relative to the `base_path` but not explicitly under it. `restrict_base_path` can be set to `False` for legacy + behavior. + ## 9.11 - **NEW**: Emoji: Update to new CDN and use Twemoji 14.1.2. diff --git a/docs/src/markdown/extensions/snippets.md b/docs/src/markdown/extensions/snippets.md index 8c3e81374..e64a9f078 100644 --- a/docs/src/markdown/extensions/snippets.md +++ b/docs/src/markdown/extensions/snippets.md @@ -4,6 +4,11 @@ ## Overview +/// warning | Not Meant for User Facing Sites +Snippets is meant to make including snippets in documentation easier, but it should not be used for user facing sites +that take and parse user content dynamically. +/// + Snippets is an extension to insert markdown or HTML snippets into another markdown file. Snippets is great for situations where you have content you need to insert into multiple documents. For instance, this document keeps all its hyperlinks in a separate file and then includes those hyperlinks at the bottom of a document via Snippets. If a link @@ -260,3 +265,4 @@ Option | Type | Default | Description `url_timeout` | float | `#!py3 10.0` | Passes an arbitrary timeout in seconds to URL requestor. By default this is set to 10 seconds. `url_request_headers` | {string:string} | `#!py3 {}` | Passes arbitrary headers to URL requestor. By default this is set to empty map. `dedent_subsections` | bool | `#!py3 False` | Remove any common leading whitespace from every line in text of a subsection that is inserted via "sections" or by "lines". +`restrict_base_path` | bool | `#!py True` | Ensure that the specified snippets are children of the specified base path(s). This prevents a path relative to the base path, but not explicitly a child of the base path. diff --git a/pymdownx/__meta__.py b/pymdownx/__meta__.py index 4e7534c5c..b8400f7e9 100644 --- a/pymdownx/__meta__.py +++ b/pymdownx/__meta__.py @@ -185,5 +185,5 @@ def parse_version(ver, pre=False): return Version(major, minor, micro, release, pre, post, dev) -__version_info__ = Version(9, 11, 0, "final") +__version_info__ = Version(10, 0, 0, "final") __version__ = __version_info__._get_canonical() diff --git a/pymdownx/snippets.py b/pymdownx/snippets.py index e7ae2e986..56b03de3e 100644 --- a/pymdownx/snippets.py +++ b/pymdownx/snippets.py @@ -82,7 +82,8 @@ def __init__(self, config, md): base = config.get('base_path') if isinstance(base, str): base = [base] - self.base_path = base + self.base_path = [os.path.abspath(b) for b in base] + self.restrict_base_path = config['restrict_base_path'] self.encoding = config.get('encoding') self.check_paths = config.get('check_paths') self.auto_append = config.get('auto_append') @@ -159,18 +160,22 @@ def get_snippet_path(self, path): for base in self.base_path: if os.path.exists(base): if os.path.isdir(base): - filename = os.path.join(base, path) + if self.restrict_base_path: + filename = os.path.abspath(os.path.join(base, path)) + # If the absolute path is no longer under the specified base path, reject the file + if not os.path.samefile(base, os.path.dirname(filename)): + continue + else: + filename = os.path.join(base, path) if os.path.exists(filename): snippet = filename break else: - basename = os.path.basename(base) dirname = os.path.dirname(base) - if basename.lower() == path.lower(): - filename = os.path.join(dirname, path) - if os.path.exists(filename): - snippet = filename - break + filename = os.path.join(dirname, path) + if os.path.exists(filename) and os.path.samefile(filename, base): + snippet = filename + break return snippet @functools.lru_cache() @@ -367,6 +372,10 @@ def __init__(self, *args, **kwargs): self.config = { 'base_path': [["."], "Base path for snippet paths - Default: [\".\"]"], + 'restrict_base_path': [ + True, + "Restrict snippet paths such that they are under the base paths - Default: True" + ], 'encoding': ["utf-8", "Encoding of snippets - Default: \"utf-8\""], 'check_paths': [False, "Make the build fail if a snippet can't be found - Default: \"False\""], "auto_append": [ diff --git a/tests/test_extensions/_snippets/nested/nested.txt b/tests/test_extensions/_snippets/nested/nested.txt new file mode 100644 index 000000000..528c55e3a --- /dev/null +++ b/tests/test_extensions/_snippets/nested/nested.txt @@ -0,0 +1 @@ +Snippet diff --git a/tests/test_extensions/test_snippets.py b/tests/test_extensions/test_snippets.py index e91a8c4de..caa1a1b2b 100644 --- a/tests/test_extensions/test_snippets.py +++ b/tests/test_extensions/test_snippets.py @@ -481,6 +481,63 @@ def test_user(self): ) +class TestSnippetsNested(util.MdCase): + """Test nested restriction.""" + + extension = [ + 'pymdownx.snippets', + ] + + extension_configs = { + 'pymdownx.snippets': { + 'base_path': os.path.join(BASE, '_snippets', 'nested'), + 'check_paths': True + } + } + + def test_restricted(self): + """Test file restriction.""" + + with self.assertRaises(SnippetMissingError): + self.check_markdown( + R''' + --8<-- "../b.txt" + ''', + ''' +

Snippet

+ ''', + True + ) + + +class TestSnippetsNestedUnrestricted(util.MdCase): + """Test nested no bounds.""" + + extension = [ + 'pymdownx.snippets', + ] + + extension_configs = { + 'pymdownx.snippets': { + 'base_path': os.path.join(BASE, '_snippets', 'nested'), + 'restrict_base_path': False + } + } + + def test_restricted(self): + """Test file restriction.""" + + self.check_markdown( + R''' + --8<-- "../b.txt" + ''', + ''' +

Snippet

+ ''', + True + ) + + class TestSnippetsAutoAppend(util.MdCase): """Test snippet file case."""