mkdocs-easylinks-plugin 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.github/workflows/publish.yml +5 -8
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.gitignore +4 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/CHANGELOG.md +21 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/PKG-INFO +2 -3
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks/plugin.py +74 -22
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/PKG-INFO +2 -3
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/pyproject.toml +3 -4
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/tests/test_plugin.py +124 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.github/workflows/test.yml +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/LICENSE +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/README.md +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks/__init__.py +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/SOURCES.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/dependency_links.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/entry_points.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/requires.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks_plugin.egg-info/top_level.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/setup.cfg +0 -0
- {mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/tests/__init__.py +0 -0
{mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/.github/workflows/publish.yml
RENAMED
|
@@ -11,6 +11,9 @@ env:
|
|
|
11
11
|
jobs:
|
|
12
12
|
publish:
|
|
13
13
|
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
permissions:
|
|
16
|
+
id-token: write
|
|
14
17
|
|
|
15
18
|
steps:
|
|
16
19
|
- uses: actions/checkout@v4
|
|
@@ -20,14 +23,8 @@ jobs:
|
|
|
20
23
|
with:
|
|
21
24
|
python-version: "3.12"
|
|
22
25
|
|
|
23
|
-
- name: Install build tools
|
|
24
|
-
run: pip install build twine
|
|
25
|
-
|
|
26
26
|
- name: Build package
|
|
27
|
-
run: python -m build
|
|
27
|
+
run: pip install build && python -m build
|
|
28
28
|
|
|
29
29
|
- name: Publish to PyPI
|
|
30
|
-
|
|
31
|
-
TWINE_USERNAME: __token__
|
|
32
|
-
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
|
33
|
-
run: twine upload dist/*
|
|
30
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.2] - 2026-04-13
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
- Fixed log injection via user-controlled filenames
|
|
9
|
+
- Fixed path traversal via malformed file paths
|
|
10
|
+
- Fixed URL scheme bypass allowing dangerous link types
|
|
11
|
+
- Fixed misconfigured `exclude_dirs` empty-string entry
|
|
12
|
+
|
|
13
|
+
### Performance
|
|
14
|
+
- `_restore_protected_blocks` now uses a single compiled regex substitution instead of N sequential `str.replace` calls, making restoration O(n) in document length
|
|
15
|
+
- `_is_safe_path` short-circuits immediately for paths containing no `..` component (the common case), avoiding the `os.path.normpath` call entirely
|
|
16
|
+
- Config flags and the stats dict are captured as locals before the per-link closure is entered, removing repeated attribute and dict lookups on every link match
|
|
17
|
+
- Relative path results are cached per page so repeated links to the same target file call `_get_relative_path` only once
|
|
18
|
+
|
|
19
|
+
## [0.2.1] - 2026-04-02
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- Replaced deprecated `license = {text = "MIT"}` with SPDX expression `license = "MIT"`
|
|
23
|
+
- Removed deprecated `License :: OSI Approved :: MIT License` classifier
|
|
24
|
+
- Bumped setuptools build requirement to `>=77.0.0` to support SPDX license format
|
|
25
|
+
|
|
5
26
|
## [0.2.0] - 2026-04-02
|
|
6
27
|
|
|
7
28
|
### Removed
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mkdocs-easylinks-plugin
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: An MkDocs plugin that allows linking to files by filename only
|
|
5
5
|
Author: Daniel Ferguson
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
8
8
|
Project-URL: Documentation, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
9
9
|
Project-URL: Repository, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
10
10
|
Keywords: mkdocs,plugin,links,cross-references
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -17,6 +17,11 @@ from mkdocs.config.defaults import MkDocsConfig
|
|
|
17
17
|
logger = logging.getLogger("mkdocs.plugins.easylinks")
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _sanitize_log(value: str) -> str:
|
|
21
|
+
"""Strip control characters from user-controlled strings before logging."""
|
|
22
|
+
return value.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
|
|
23
|
+
|
|
24
|
+
|
|
20
25
|
class EasyLinksConfig(Config):
|
|
21
26
|
warn_on_missing = config_options.Type(bool, default=True)
|
|
22
27
|
warn_on_ambiguous = config_options.Type(bool, default=True)
|
|
@@ -61,12 +66,24 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
61
66
|
self.stats = {key: 0 for key in self.stats}
|
|
62
67
|
self.link_counts = defaultdict(int)
|
|
63
68
|
self._normalized_exclude_dirs = [
|
|
64
|
-
d.replace("\\", "/").rstrip("/") + "/"
|
|
69
|
+
d.replace("\\", "/").rstrip("/") + "/"
|
|
70
|
+
for d in self.config["exclude_dirs"]
|
|
71
|
+
if d # skip empty strings — they would normalize to "/" and exclude everything
|
|
65
72
|
]
|
|
66
73
|
|
|
67
74
|
# Process all files (documentation pages, images, etc.)
|
|
68
75
|
for file in files:
|
|
69
76
|
self.stats["total_files_scanned"] += 1
|
|
77
|
+
|
|
78
|
+
# Reject paths that escape the docs root (e.g. via symlink traversal)
|
|
79
|
+
if not self._is_safe_path(file.src_path):
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"easylinks: Skipping file with path outside docs root: "
|
|
82
|
+
f"'{_sanitize_log(file.src_path)}'"
|
|
83
|
+
)
|
|
84
|
+
self.stats["files_ignored"] += 1
|
|
85
|
+
continue
|
|
86
|
+
|
|
70
87
|
filename = os.path.basename(file.src_path)
|
|
71
88
|
|
|
72
89
|
# Ignore files starting with a dot (hidden files)
|
|
@@ -96,15 +113,29 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
96
113
|
# Warn about ambiguous files
|
|
97
114
|
if self.config["warn_on_ambiguous"] and self.ambiguous_files:
|
|
98
115
|
for filename, paths in self.ambiguous_files.items():
|
|
116
|
+
safe_filename = _sanitize_log(filename)
|
|
117
|
+
safe_paths = [_sanitize_log(p) for p in paths]
|
|
99
118
|
logger.warning(
|
|
100
|
-
f"easylinks: Ambiguous filename '{
|
|
101
|
-
+ "\n".join(f" - {
|
|
119
|
+
f"easylinks: Ambiguous filename '{safe_filename}' found in multiple locations:\n"
|
|
120
|
+
+ "\n".join(f" - {p}" for p in safe_paths)
|
|
102
121
|
+ "\nLinks to this file will use the first occurrence. "
|
|
103
122
|
"Consider using full paths for disambiguation."
|
|
104
123
|
)
|
|
105
124
|
|
|
106
125
|
return files
|
|
107
126
|
|
|
127
|
+
def _is_safe_path(self, src_path: str) -> bool:
|
|
128
|
+
"""Return True if src_path stays within the docs root.
|
|
129
|
+
|
|
130
|
+
MkDocs supplies src_path as a relative path (e.g. ``subdir/page.md``).
|
|
131
|
+
A path that escapes the docs root after normalization (e.g.
|
|
132
|
+
``../../etc/passwd``) would produce a traversal link in the output and
|
|
133
|
+
must be rejected.
|
|
134
|
+
"""
|
|
135
|
+
if ".." not in src_path:
|
|
136
|
+
return True
|
|
137
|
+
return not os.path.normpath(src_path).startswith("..")
|
|
138
|
+
|
|
108
139
|
def _is_excluded_dir(self, file_path: str) -> bool:
|
|
109
140
|
"""Check if a file is in an excluded directory."""
|
|
110
141
|
if not self._normalized_exclude_dirs:
|
|
@@ -128,14 +159,28 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
128
159
|
# Extract code fences and HTML comments before processing
|
|
129
160
|
markdown, protected_blocks = self._extract_protected_blocks(markdown)
|
|
130
161
|
|
|
162
|
+
# Capture config flags and stats dict as locals to avoid repeated
|
|
163
|
+
# attribute + dict lookups inside the closure on every link match.
|
|
164
|
+
warn_on_ambiguous = self.config["warn_on_ambiguous"]
|
|
165
|
+
warn_on_missing = self.config["warn_on_missing"]
|
|
166
|
+
stats = self.stats
|
|
167
|
+
page_src_path = page.file.src_path
|
|
168
|
+
# Cache relative-path results within this page: from_path is constant,
|
|
169
|
+
# so keying on to_path alone is sufficient.
|
|
170
|
+
relative_path_cache: Dict[str, str] = {}
|
|
171
|
+
|
|
131
172
|
def replace_link(match):
|
|
132
173
|
is_image = match.group(1) # Will be '!' for images, None for regular links
|
|
133
174
|
link_text = match.group(2)
|
|
134
175
|
link_url = match.group(3)
|
|
135
176
|
|
|
136
|
-
# Skip
|
|
177
|
+
# Skip external links, anchors, absolute paths, and any URL with a
|
|
178
|
+
# scheme (colon present). Using a colon as the scheme sentinel is an
|
|
179
|
+
# intentional allowlist: bare filenames never contain colons, so any
|
|
180
|
+
# URL that does (http:, https:, javascript:, data:, file:, mailto:,
|
|
181
|
+
# blob:, vbscript:, etc.) is left untouched rather than resolved.
|
|
137
182
|
if (link_url.startswith(('http://', 'https://', '//', '#', '/'))
|
|
138
|
-
or
|
|
183
|
+
or ':' in link_url):
|
|
139
184
|
return match.group(0)
|
|
140
185
|
|
|
141
186
|
# Extract anchor if present (only relevant for links, not images)
|
|
@@ -148,45 +193,51 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
148
193
|
if "/" not in link_url and "\\" not in link_url:
|
|
149
194
|
# Track statistics
|
|
150
195
|
if is_image:
|
|
151
|
-
|
|
196
|
+
stats["images_processed"] += 1
|
|
152
197
|
else:
|
|
153
|
-
|
|
198
|
+
stats["links_processed"] += 1
|
|
154
199
|
|
|
155
200
|
resolved_path = self._resolve_filename(link_url)
|
|
156
201
|
if resolved_path:
|
|
157
202
|
# Warn if this filename is ambiguous
|
|
158
|
-
if
|
|
203
|
+
if warn_on_ambiguous and link_url in self.ambiguous_files:
|
|
159
204
|
all_paths = self.ambiguous_files[link_url]
|
|
160
205
|
logger.warning(
|
|
161
|
-
f"easylinks: Ambiguous filename '{link_url}' referred to in "
|
|
162
|
-
f"'{
|
|
163
|
-
f"
|
|
206
|
+
f"easylinks: Ambiguous filename '{_sanitize_log(link_url)}' referred to in "
|
|
207
|
+
f"'{_sanitize_log(page_src_path)}': exists at "
|
|
208
|
+
f"{[_sanitize_log(p) for p in all_paths]}. "
|
|
209
|
+
f"Using '{_sanitize_log(resolved_path)}'."
|
|
164
210
|
)
|
|
165
211
|
|
|
166
212
|
# Track successful resolution
|
|
167
213
|
if is_image:
|
|
168
|
-
|
|
214
|
+
stats["images_resolved"] += 1
|
|
169
215
|
else:
|
|
170
|
-
|
|
216
|
+
stats["links_resolved"] += 1
|
|
171
217
|
# Count how many times each file is linked
|
|
172
218
|
self.link_counts[resolved_path] += 1
|
|
173
219
|
|
|
174
|
-
# Calculate relative path from current page to target
|
|
175
|
-
|
|
220
|
+
# Calculate relative path from current page to target; cache
|
|
221
|
+
# the result since from_path is constant for this page.
|
|
222
|
+
if resolved_path not in relative_path_cache:
|
|
223
|
+
relative_path_cache[resolved_path] = self._get_relative_path(
|
|
224
|
+
page_src_path, resolved_path
|
|
225
|
+
)
|
|
226
|
+
relative_path = relative_path_cache[resolved_path]
|
|
176
227
|
# Reconstruct with or without the ! prefix
|
|
177
228
|
prefix = "!" if is_image else ""
|
|
178
229
|
return f"{prefix}[{link_text}]({relative_path}{anchor})"
|
|
179
230
|
else:
|
|
180
231
|
# Track unresolved links/images separately
|
|
181
232
|
if is_image:
|
|
182
|
-
|
|
233
|
+
stats["images_unresolved"] += 1
|
|
183
234
|
else:
|
|
184
|
-
|
|
235
|
+
stats["links_unresolved"] += 1
|
|
185
236
|
|
|
186
|
-
if
|
|
237
|
+
if warn_on_missing:
|
|
187
238
|
file_type = "image" if is_image else "file"
|
|
188
239
|
logger.warning(
|
|
189
|
-
f"easylinks: Could not resolve {file_type} link to '{link_url}' on page '{
|
|
240
|
+
f"easylinks: Could not resolve {file_type} link to '{_sanitize_log(link_url)}' on page '{_sanitize_log(page_src_path)}'"
|
|
190
241
|
)
|
|
191
242
|
|
|
192
243
|
return match.group(0)
|
|
@@ -229,9 +280,10 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
229
280
|
|
|
230
281
|
def _restore_protected_blocks(self, markdown: str, protected_blocks: dict) -> str:
|
|
231
282
|
"""Restore protected blocks from placeholders."""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
283
|
+
if not protected_blocks:
|
|
284
|
+
return markdown
|
|
285
|
+
pattern = re.compile('|'.join(re.escape(k) for k in protected_blocks))
|
|
286
|
+
return pattern.sub(lambda m: protected_blocks[m.group(0)], markdown)
|
|
235
287
|
|
|
236
288
|
def _resolve_filename(self, filename: str) -> Optional[str]:
|
|
237
289
|
"""Resolve a filename to its full path within the docs."""
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mkdocs-easylinks-plugin
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: An MkDocs plugin that allows linking to files by filename only
|
|
5
5
|
Author: Daniel Ferguson
|
|
6
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
8
8
|
Project-URL: Documentation, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
9
9
|
Project-URL: Repository, https://github.com/dsferg/mkdocs-easylinks-plugin
|
|
10
10
|
Keywords: mkdocs,plugin,links,cross-references
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.11
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools>=
|
|
2
|
+
requires = ["setuptools>=77.0.0", "wheel", "setuptools_scm[toml]>=6.2"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mkdocs-easylinks-plugin"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "An MkDocs plugin that allows linking to files by filename only"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
|
-
license =
|
|
11
|
+
license = "MIT"
|
|
12
12
|
authors = [
|
|
13
13
|
{name = "Daniel Ferguson"}
|
|
14
14
|
]
|
|
@@ -16,7 +16,6 @@ keywords = ["mkdocs", "plugin", "links", "cross-references"]
|
|
|
16
16
|
classifiers = [
|
|
17
17
|
"Development Status :: 4 - Beta",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
|
-
"License :: OSI Approved :: MIT License",
|
|
20
19
|
"Programming Language :: Python :: 3",
|
|
21
20
|
"Programming Language :: Python :: 3.10",
|
|
22
21
|
"Programming Language :: Python :: 3.11",
|
|
@@ -1152,3 +1152,127 @@ Regular paragraph with [example link](example.md).
|
|
|
1152
1152
|
|
|
1153
1153
|
# Image in admonition should be processed
|
|
1154
1154
|
assert "../images/diagram.png" in result
|
|
1155
|
+
|
|
1156
|
+
# ------------------------------------------------------------------
|
|
1157
|
+
# Security fix: log injection (_sanitize_log)
|
|
1158
|
+
# ------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
def test_sanitize_log_escapes_newlines(self):
|
|
1161
|
+
"""_sanitize_log must escape newlines so they cannot inject fake log lines."""
|
|
1162
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1163
|
+
assert _sanitize_log("file\nINJECTED WARNING.md") == "file\\nINJECTED WARNING.md"
|
|
1164
|
+
|
|
1165
|
+
def test_sanitize_log_escapes_carriage_returns(self):
|
|
1166
|
+
"""_sanitize_log must escape carriage returns."""
|
|
1167
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1168
|
+
assert _sanitize_log("file\r.md") == "file\\r.md"
|
|
1169
|
+
|
|
1170
|
+
def test_sanitize_log_escapes_tabs(self):
|
|
1171
|
+
"""_sanitize_log must escape tab characters."""
|
|
1172
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1173
|
+
assert _sanitize_log("file\t.md") == "file\\t.md"
|
|
1174
|
+
|
|
1175
|
+
def test_sanitize_log_normal_string_unchanged(self):
|
|
1176
|
+
"""_sanitize_log must leave strings without control characters unchanged."""
|
|
1177
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1178
|
+
assert _sanitize_log("subdir/normal-file.md") == "subdir/normal-file.md"
|
|
1179
|
+
|
|
1180
|
+
def test_sanitize_log_multiple_control_chars(self):
|
|
1181
|
+
"""_sanitize_log must escape all control character types in one pass."""
|
|
1182
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1183
|
+
assert _sanitize_log("a\nb\rc\td") == "a\\nb\\rc\\td"
|
|
1184
|
+
|
|
1185
|
+
# ------------------------------------------------------------------
|
|
1186
|
+
# Security fix: path traversal (_is_safe_path / on_files)
|
|
1187
|
+
# ------------------------------------------------------------------
|
|
1188
|
+
|
|
1189
|
+
def test_is_safe_path_normal_path(self):
|
|
1190
|
+
"""A normal relative path should be considered safe."""
|
|
1191
|
+
assert self.plugin._is_safe_path("subdir/file.md") is True
|
|
1192
|
+
|
|
1193
|
+
def test_is_safe_path_root_level_file(self):
|
|
1194
|
+
"""A filename at the root level should be safe."""
|
|
1195
|
+
assert self.plugin._is_safe_path("file.md") is True
|
|
1196
|
+
|
|
1197
|
+
def test_is_safe_path_traversal_escape(self):
|
|
1198
|
+
"""A path that escapes the docs root must be rejected."""
|
|
1199
|
+
assert self.plugin._is_safe_path("../../etc/passwd") is False
|
|
1200
|
+
|
|
1201
|
+
def test_is_safe_path_single_parent_traversal(self):
|
|
1202
|
+
"""A path starting with .. must be rejected."""
|
|
1203
|
+
assert self.plugin._is_safe_path("../outside.md") is False
|
|
1204
|
+
|
|
1205
|
+
def test_is_safe_path_embedded_traversal_that_escapes(self):
|
|
1206
|
+
"""An embedded .. sequence that resolves outside the root must be rejected."""
|
|
1207
|
+
assert self.plugin._is_safe_path("subdir/../../../escape.md") is False
|
|
1208
|
+
|
|
1209
|
+
def test_is_safe_path_embedded_traversal_that_stays_inside(self):
|
|
1210
|
+
"""An embedded .. that stays within the root should be accepted."""
|
|
1211
|
+
assert self.plugin._is_safe_path("subdir/../other/file.md") is True
|
|
1212
|
+
|
|
1213
|
+
def test_traversal_path_ignored_in_on_files(self):
|
|
1214
|
+
"""Files whose src_path escapes the docs root must be excluded from the index."""
|
|
1215
|
+
mock_config = MagicMock()
|
|
1216
|
+
mock_files = MagicMock(spec=Files)
|
|
1217
|
+
|
|
1218
|
+
safe_file = self.create_mock_file("docs/safe.md")
|
|
1219
|
+
traversal_file = self.create_mock_file("../../secret.md")
|
|
1220
|
+
|
|
1221
|
+
mock_files.__iter__ = MagicMock(return_value=iter([safe_file, traversal_file]))
|
|
1222
|
+
|
|
1223
|
+
self.plugin.on_files(mock_files, config=mock_config)
|
|
1224
|
+
|
|
1225
|
+
assert "safe.md" in self.plugin.file_map
|
|
1226
|
+
assert "secret.md" not in self.plugin.file_map
|
|
1227
|
+
assert self.plugin.stats["files_ignored"] == 1
|
|
1228
|
+
|
|
1229
|
+
# ------------------------------------------------------------------
|
|
1230
|
+
# Security fix: URL scheme allowlist
|
|
1231
|
+
# ------------------------------------------------------------------
|
|
1232
|
+
|
|
1233
|
+
def test_dangerous_url_schemes_unchanged(self):
|
|
1234
|
+
"""Links with dangerous URL schemes must be returned untouched."""
|
|
1235
|
+
page = self.create_mock_page("docs/index.md")
|
|
1236
|
+
|
|
1237
|
+
dangerous = [
|
|
1238
|
+
"[XSS](javascript:alert(1))",
|
|
1239
|
+
"[Data](data:text/html,<h1>test</h1>)",
|
|
1240
|
+
"[VB](vbscript:msgbox('xss'))",
|
|
1241
|
+
"[Blob](blob:https://example.com/some-uuid)",
|
|
1242
|
+
]
|
|
1243
|
+
|
|
1244
|
+
for markdown in dangerous:
|
|
1245
|
+
result = self.plugin._process_links(markdown, page)
|
|
1246
|
+
assert result == markdown, f"Expected unchanged: {markdown}"
|
|
1247
|
+
|
|
1248
|
+
def test_file_url_unchanged(self):
|
|
1249
|
+
"""file: URLs must be passed through unchanged, not treated as filenames."""
|
|
1250
|
+
# Even if a matching basename happened to be indexed, the scheme must win.
|
|
1251
|
+
self.plugin.file_map = {"passwd": "/etc/passwd"}
|
|
1252
|
+
page = self.create_mock_page("docs/index.md")
|
|
1253
|
+
markdown = "[Secret](file:///etc/passwd)"
|
|
1254
|
+
|
|
1255
|
+
result = self.plugin._process_links(markdown, page)
|
|
1256
|
+
assert result == markdown
|
|
1257
|
+
|
|
1258
|
+
# ------------------------------------------------------------------
|
|
1259
|
+
# Security fix: empty string in exclude_dirs
|
|
1260
|
+
# ------------------------------------------------------------------
|
|
1261
|
+
|
|
1262
|
+
def test_exclude_dirs_empty_string_does_not_exclude_all(self):
|
|
1263
|
+
"""An empty string entry in exclude_dirs must not silently exclude every file."""
|
|
1264
|
+
self.plugin.config["exclude_dirs"] = [""]
|
|
1265
|
+
|
|
1266
|
+
mock_config = MagicMock()
|
|
1267
|
+
mock_files = MagicMock(spec=Files)
|
|
1268
|
+
|
|
1269
|
+
file1 = self.create_mock_file("docs/page.md")
|
|
1270
|
+
file2 = self.create_mock_file("docs/another.md")
|
|
1271
|
+
|
|
1272
|
+
mock_files.__iter__ = MagicMock(return_value=iter([file1, file2]))
|
|
1273
|
+
|
|
1274
|
+
self.plugin.on_files(mock_files, config=mock_config)
|
|
1275
|
+
|
|
1276
|
+
assert "page.md" in self.plugin.file_map
|
|
1277
|
+
assert "another.md" in self.plugin.file_map
|
|
1278
|
+
assert self.plugin.stats["files_indexed"] == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{mkdocs_easylinks_plugin-0.2.0 → mkdocs_easylinks_plugin-0.2.2}/mkdocs_easylinks/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|