mkdocs-easylinks-plugin 0.2.1__tar.gz → 0.2.3__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.1 → mkdocs_easylinks_plugin-0.2.3}/.github/workflows/publish.yml +5 -8
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.gitignore +4 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/CHANGELOG.md +23 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/PKG-INFO +1 -1
- mkdocs_easylinks_plugin-0.2.3/mkdocs_easylinks/__init__.py +8 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks/plugin.py +99 -22
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/PKG-INFO +1 -1
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/pyproject.toml +1 -1
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/tests/test_plugin.py +162 -0
- mkdocs_easylinks_plugin-0.2.1/mkdocs_easylinks/__init__.py +0 -3
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.github/workflows/test.yml +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/LICENSE +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/README.md +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/SOURCES.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/dependency_links.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/entry_points.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/requires.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/top_level.txt +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/setup.cfg +0 -0
- {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/tests/__init__.py +0 -0
{mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.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,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.3] - 2026-04-29
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
- Expanded log sanitizer to escape ANSI escape sequences, NUL bytes, vertical tab, form feed, and Unicode line separators (U+2028/U+2029) in addition to `\n`, `\r`, and `\t`
|
|
9
|
+
- Path safety check now rejects absolute and drive-relative paths (e.g. `/etc/passwd`, `C:\…`, `\\server\share\…`) in addition to `..` traversal
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `mkdocs_easylinks.__version__` is now read from installed package metadata, eliminating drift between `pyproject.toml` and the module attribute
|
|
13
|
+
|
|
14
|
+
## [0.2.2] - 2026-04-13
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
- Fixed log injection via user-controlled filenames
|
|
18
|
+
- Fixed path traversal via malformed file paths
|
|
19
|
+
- Fixed URL scheme bypass allowing dangerous link types
|
|
20
|
+
- Fixed misconfigured `exclude_dirs` empty-string entry
|
|
21
|
+
|
|
22
|
+
### Performance
|
|
23
|
+
- `_restore_protected_blocks` now uses a single compiled regex substitution instead of N sequential `str.replace` calls, making restoration O(n) in document length
|
|
24
|
+
- `_is_safe_path` short-circuits immediately for paths containing no `..` component (the common case), avoiding the `os.path.normpath` call entirely
|
|
25
|
+
- 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
|
|
26
|
+
- Relative path results are cached per page so repeated links to the same target file call `_get_relative_path` only once
|
|
27
|
+
|
|
5
28
|
## [0.2.1] - 2026-04-02
|
|
6
29
|
|
|
7
30
|
### Fixed
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""MkDocs EasyLinks Plugin - Simplified cross-referencing by filename."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("mkdocs-easylinks-plugin")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "unknown"
|
|
@@ -17,6 +17,30 @@ from mkdocs.config.defaults import MkDocsConfig
|
|
|
17
17
|
logger = logging.getLogger("mkdocs.plugins.easylinks")
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
_LOG_ESCAPES = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sanitize_log(value: str) -> str:
|
|
24
|
+
"""Escape control characters and other non-printable codepoints before logging.
|
|
25
|
+
|
|
26
|
+
Filenames can legally contain ANSI escape sequences, NULs, vertical tabs,
|
|
27
|
+
form feeds, or Unicode line separators on POSIX systems. Logging them
|
|
28
|
+
verbatim would let an attacker spoof log lines, recolor output, or
|
|
29
|
+
manipulate a terminal that tails the build log.
|
|
30
|
+
"""
|
|
31
|
+
out = []
|
|
32
|
+
for c in value:
|
|
33
|
+
if c in _LOG_ESCAPES:
|
|
34
|
+
out.append(_LOG_ESCAPES[c])
|
|
35
|
+
elif c.isprintable():
|
|
36
|
+
out.append(c)
|
|
37
|
+
elif ord(c) <= 0xff:
|
|
38
|
+
out.append(f"\\x{ord(c):02x}")
|
|
39
|
+
else:
|
|
40
|
+
out.append(f"\\u{ord(c):04x}")
|
|
41
|
+
return "".join(out)
|
|
42
|
+
|
|
43
|
+
|
|
20
44
|
class EasyLinksConfig(Config):
|
|
21
45
|
warn_on_missing = config_options.Type(bool, default=True)
|
|
22
46
|
warn_on_ambiguous = config_options.Type(bool, default=True)
|
|
@@ -61,12 +85,24 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
61
85
|
self.stats = {key: 0 for key in self.stats}
|
|
62
86
|
self.link_counts = defaultdict(int)
|
|
63
87
|
self._normalized_exclude_dirs = [
|
|
64
|
-
d.replace("\\", "/").rstrip("/") + "/"
|
|
88
|
+
d.replace("\\", "/").rstrip("/") + "/"
|
|
89
|
+
for d in self.config["exclude_dirs"]
|
|
90
|
+
if d # skip empty strings — they would normalize to "/" and exclude everything
|
|
65
91
|
]
|
|
66
92
|
|
|
67
93
|
# Process all files (documentation pages, images, etc.)
|
|
68
94
|
for file in files:
|
|
69
95
|
self.stats["total_files_scanned"] += 1
|
|
96
|
+
|
|
97
|
+
# Reject paths that escape the docs root (e.g. via symlink traversal)
|
|
98
|
+
if not self._is_safe_path(file.src_path):
|
|
99
|
+
logger.warning(
|
|
100
|
+
f"easylinks: Skipping file with path outside docs root: "
|
|
101
|
+
f"'{_sanitize_log(file.src_path)}'"
|
|
102
|
+
)
|
|
103
|
+
self.stats["files_ignored"] += 1
|
|
104
|
+
continue
|
|
105
|
+
|
|
70
106
|
filename = os.path.basename(file.src_path)
|
|
71
107
|
|
|
72
108
|
# Ignore files starting with a dot (hidden files)
|
|
@@ -96,15 +132,35 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
96
132
|
# Warn about ambiguous files
|
|
97
133
|
if self.config["warn_on_ambiguous"] and self.ambiguous_files:
|
|
98
134
|
for filename, paths in self.ambiguous_files.items():
|
|
135
|
+
safe_filename = _sanitize_log(filename)
|
|
136
|
+
safe_paths = [_sanitize_log(p) for p in paths]
|
|
99
137
|
logger.warning(
|
|
100
|
-
f"easylinks: Ambiguous filename '{
|
|
101
|
-
+ "\n".join(f" - {
|
|
138
|
+
f"easylinks: Ambiguous filename '{safe_filename}' found in multiple locations:\n"
|
|
139
|
+
+ "\n".join(f" - {p}" for p in safe_paths)
|
|
102
140
|
+ "\nLinks to this file will use the first occurrence. "
|
|
103
141
|
"Consider using full paths for disambiguation."
|
|
104
142
|
)
|
|
105
143
|
|
|
106
144
|
return files
|
|
107
145
|
|
|
146
|
+
def _is_safe_path(self, src_path: str) -> bool:
|
|
147
|
+
"""Return True if src_path stays within the docs root.
|
|
148
|
+
|
|
149
|
+
MkDocs supplies src_path as a relative path (e.g. ``subdir/page.md``).
|
|
150
|
+
Absolute paths (``/etc/passwd``, ``C:\\Windows\\...``, UNC paths) and
|
|
151
|
+
relative paths that escape the docs root after normalization (e.g.
|
|
152
|
+
``../../etc/passwd``) would both produce traversal links in the
|
|
153
|
+
output and must be rejected.
|
|
154
|
+
"""
|
|
155
|
+
# os.path.isabs alone is not enough: on Python 3.13+ Windows it returns
|
|
156
|
+
# False for drive-relative paths like ``/etc/passwd`` or ``\foo``, which
|
|
157
|
+
# would still escape the docs root once a drive is resolved.
|
|
158
|
+
if os.path.isabs(src_path) or src_path.startswith(("/", "\\")):
|
|
159
|
+
return False
|
|
160
|
+
if ".." not in src_path:
|
|
161
|
+
return True
|
|
162
|
+
return not os.path.normpath(src_path).startswith("..")
|
|
163
|
+
|
|
108
164
|
def _is_excluded_dir(self, file_path: str) -> bool:
|
|
109
165
|
"""Check if a file is in an excluded directory."""
|
|
110
166
|
if not self._normalized_exclude_dirs:
|
|
@@ -128,14 +184,28 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
128
184
|
# Extract code fences and HTML comments before processing
|
|
129
185
|
markdown, protected_blocks = self._extract_protected_blocks(markdown)
|
|
130
186
|
|
|
187
|
+
# Capture config flags and stats dict as locals to avoid repeated
|
|
188
|
+
# attribute + dict lookups inside the closure on every link match.
|
|
189
|
+
warn_on_ambiguous = self.config["warn_on_ambiguous"]
|
|
190
|
+
warn_on_missing = self.config["warn_on_missing"]
|
|
191
|
+
stats = self.stats
|
|
192
|
+
page_src_path = page.file.src_path
|
|
193
|
+
# Cache relative-path results within this page: from_path is constant,
|
|
194
|
+
# so keying on to_path alone is sufficient.
|
|
195
|
+
relative_path_cache: Dict[str, str] = {}
|
|
196
|
+
|
|
131
197
|
def replace_link(match):
|
|
132
198
|
is_image = match.group(1) # Will be '!' for images, None for regular links
|
|
133
199
|
link_text = match.group(2)
|
|
134
200
|
link_url = match.group(3)
|
|
135
201
|
|
|
136
|
-
# Skip
|
|
202
|
+
# Skip external links, anchors, absolute paths, and any URL with a
|
|
203
|
+
# scheme (colon present). Using a colon as the scheme sentinel is an
|
|
204
|
+
# intentional allowlist: bare filenames never contain colons, so any
|
|
205
|
+
# URL that does (http:, https:, javascript:, data:, file:, mailto:,
|
|
206
|
+
# blob:, vbscript:, etc.) is left untouched rather than resolved.
|
|
137
207
|
if (link_url.startswith(('http://', 'https://', '//', '#', '/'))
|
|
138
|
-
or
|
|
208
|
+
or ':' in link_url):
|
|
139
209
|
return match.group(0)
|
|
140
210
|
|
|
141
211
|
# Extract anchor if present (only relevant for links, not images)
|
|
@@ -148,45 +218,51 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
148
218
|
if "/" not in link_url and "\\" not in link_url:
|
|
149
219
|
# Track statistics
|
|
150
220
|
if is_image:
|
|
151
|
-
|
|
221
|
+
stats["images_processed"] += 1
|
|
152
222
|
else:
|
|
153
|
-
|
|
223
|
+
stats["links_processed"] += 1
|
|
154
224
|
|
|
155
225
|
resolved_path = self._resolve_filename(link_url)
|
|
156
226
|
if resolved_path:
|
|
157
227
|
# Warn if this filename is ambiguous
|
|
158
|
-
if
|
|
228
|
+
if warn_on_ambiguous and link_url in self.ambiguous_files:
|
|
159
229
|
all_paths = self.ambiguous_files[link_url]
|
|
160
230
|
logger.warning(
|
|
161
|
-
f"easylinks: Ambiguous filename '{link_url}' referred to in "
|
|
162
|
-
f"'{
|
|
163
|
-
f"
|
|
231
|
+
f"easylinks: Ambiguous filename '{_sanitize_log(link_url)}' referred to in "
|
|
232
|
+
f"'{_sanitize_log(page_src_path)}': exists at "
|
|
233
|
+
f"{[_sanitize_log(p) for p in all_paths]}. "
|
|
234
|
+
f"Using '{_sanitize_log(resolved_path)}'."
|
|
164
235
|
)
|
|
165
236
|
|
|
166
237
|
# Track successful resolution
|
|
167
238
|
if is_image:
|
|
168
|
-
|
|
239
|
+
stats["images_resolved"] += 1
|
|
169
240
|
else:
|
|
170
|
-
|
|
241
|
+
stats["links_resolved"] += 1
|
|
171
242
|
# Count how many times each file is linked
|
|
172
243
|
self.link_counts[resolved_path] += 1
|
|
173
244
|
|
|
174
|
-
# Calculate relative path from current page to target
|
|
175
|
-
|
|
245
|
+
# Calculate relative path from current page to target; cache
|
|
246
|
+
# the result since from_path is constant for this page.
|
|
247
|
+
if resolved_path not in relative_path_cache:
|
|
248
|
+
relative_path_cache[resolved_path] = self._get_relative_path(
|
|
249
|
+
page_src_path, resolved_path
|
|
250
|
+
)
|
|
251
|
+
relative_path = relative_path_cache[resolved_path]
|
|
176
252
|
# Reconstruct with or without the ! prefix
|
|
177
253
|
prefix = "!" if is_image else ""
|
|
178
254
|
return f"{prefix}[{link_text}]({relative_path}{anchor})"
|
|
179
255
|
else:
|
|
180
256
|
# Track unresolved links/images separately
|
|
181
257
|
if is_image:
|
|
182
|
-
|
|
258
|
+
stats["images_unresolved"] += 1
|
|
183
259
|
else:
|
|
184
|
-
|
|
260
|
+
stats["links_unresolved"] += 1
|
|
185
261
|
|
|
186
|
-
if
|
|
262
|
+
if warn_on_missing:
|
|
187
263
|
file_type = "image" if is_image else "file"
|
|
188
264
|
logger.warning(
|
|
189
|
-
f"easylinks: Could not resolve {file_type} link to '{link_url}' on page '{
|
|
265
|
+
f"easylinks: Could not resolve {file_type} link to '{_sanitize_log(link_url)}' on page '{_sanitize_log(page_src_path)}'"
|
|
190
266
|
)
|
|
191
267
|
|
|
192
268
|
return match.group(0)
|
|
@@ -229,9 +305,10 @@ class EasyLinksPlugin(BasePlugin[EasyLinksConfig]):
|
|
|
229
305
|
|
|
230
306
|
def _restore_protected_blocks(self, markdown: str, protected_blocks: dict) -> str:
|
|
231
307
|
"""Restore protected blocks from placeholders."""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
308
|
+
if not protected_blocks:
|
|
309
|
+
return markdown
|
|
310
|
+
pattern = re.compile('|'.join(re.escape(k) for k in protected_blocks))
|
|
311
|
+
return pattern.sub(lambda m: protected_blocks[m.group(0)], markdown)
|
|
235
312
|
|
|
236
313
|
def _resolve_filename(self, filename: str) -> Optional[str]:
|
|
237
314
|
"""Resolve a filename to its full path within the docs."""
|
|
@@ -1152,3 +1152,165 @@ 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
|
+
def test_sanitize_log_escapes_ansi_escape(self):
|
|
1186
|
+
"""_sanitize_log must escape ESC so terminals tailing logs cannot be hijacked."""
|
|
1187
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1188
|
+
assert _sanitize_log("file\x1b[31mRED.md") == "file\\x1b[31mRED.md"
|
|
1189
|
+
|
|
1190
|
+
def test_sanitize_log_escapes_null_byte(self):
|
|
1191
|
+
"""_sanitize_log must escape NUL bytes."""
|
|
1192
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1193
|
+
assert _sanitize_log("file\x00.md") == "file\\x00.md"
|
|
1194
|
+
|
|
1195
|
+
def test_sanitize_log_escapes_vertical_tab_and_form_feed(self):
|
|
1196
|
+
"""_sanitize_log must escape \\v and \\f, which can move the cursor in some terminals."""
|
|
1197
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1198
|
+
assert _sanitize_log("a\vb\fc") == "a\\x0bb\\x0cc"
|
|
1199
|
+
|
|
1200
|
+
def test_sanitize_log_escapes_unicode_line_separator(self):
|
|
1201
|
+
"""_sanitize_log must escape U+2028 and U+2029, which act as line breaks in some viewers."""
|
|
1202
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1203
|
+
assert _sanitize_log("a
b
c") == "a\\u2028b\\u2029c"
|
|
1204
|
+
|
|
1205
|
+
def test_sanitize_log_preserves_printable_unicode(self):
|
|
1206
|
+
"""_sanitize_log must leave printable Unicode (accents, emoji, CJK) alone."""
|
|
1207
|
+
from mkdocs_easylinks.plugin import _sanitize_log
|
|
1208
|
+
assert _sanitize_log("café-日本語-🚀.md") == "café-日本語-🚀.md"
|
|
1209
|
+
|
|
1210
|
+
# ------------------------------------------------------------------
|
|
1211
|
+
# Security fix: path traversal (_is_safe_path / on_files)
|
|
1212
|
+
# ------------------------------------------------------------------
|
|
1213
|
+
|
|
1214
|
+
def test_is_safe_path_normal_path(self):
|
|
1215
|
+
"""A normal relative path should be considered safe."""
|
|
1216
|
+
assert self.plugin._is_safe_path("subdir/file.md") is True
|
|
1217
|
+
|
|
1218
|
+
def test_is_safe_path_root_level_file(self):
|
|
1219
|
+
"""A filename at the root level should be safe."""
|
|
1220
|
+
assert self.plugin._is_safe_path("file.md") is True
|
|
1221
|
+
|
|
1222
|
+
def test_is_safe_path_traversal_escape(self):
|
|
1223
|
+
"""A path that escapes the docs root must be rejected."""
|
|
1224
|
+
assert self.plugin._is_safe_path("../../etc/passwd") is False
|
|
1225
|
+
|
|
1226
|
+
def test_is_safe_path_single_parent_traversal(self):
|
|
1227
|
+
"""A path starting with .. must be rejected."""
|
|
1228
|
+
assert self.plugin._is_safe_path("../outside.md") is False
|
|
1229
|
+
|
|
1230
|
+
def test_is_safe_path_embedded_traversal_that_escapes(self):
|
|
1231
|
+
"""An embedded .. sequence that resolves outside the root must be rejected."""
|
|
1232
|
+
assert self.plugin._is_safe_path("subdir/../../../escape.md") is False
|
|
1233
|
+
|
|
1234
|
+
def test_is_safe_path_embedded_traversal_that_stays_inside(self):
|
|
1235
|
+
"""An embedded .. that stays within the root should be accepted."""
|
|
1236
|
+
assert self.plugin._is_safe_path("subdir/../other/file.md") is True
|
|
1237
|
+
|
|
1238
|
+
def test_is_safe_path_rejects_posix_absolute(self):
|
|
1239
|
+
"""An absolute POSIX path must be rejected even though it has no '..'."""
|
|
1240
|
+
assert self.plugin._is_safe_path("/etc/passwd") is False
|
|
1241
|
+
|
|
1242
|
+
def test_is_safe_path_rejects_windows_absolute(self):
|
|
1243
|
+
"""An absolute Windows path must be rejected when running on Windows."""
|
|
1244
|
+
import os
|
|
1245
|
+
import pytest
|
|
1246
|
+
if os.name != "nt":
|
|
1247
|
+
pytest.skip("Windows-specific path semantics")
|
|
1248
|
+
assert self.plugin._is_safe_path(r"C:\Windows\System32\config\SAM") is False
|
|
1249
|
+
assert self.plugin._is_safe_path(r"\\server\share\file.md") is False
|
|
1250
|
+
|
|
1251
|
+
def test_traversal_path_ignored_in_on_files(self):
|
|
1252
|
+
"""Files whose src_path escapes the docs root must be excluded from the index."""
|
|
1253
|
+
mock_config = MagicMock()
|
|
1254
|
+
mock_files = MagicMock(spec=Files)
|
|
1255
|
+
|
|
1256
|
+
safe_file = self.create_mock_file("docs/safe.md")
|
|
1257
|
+
traversal_file = self.create_mock_file("../../secret.md")
|
|
1258
|
+
|
|
1259
|
+
mock_files.__iter__ = MagicMock(return_value=iter([safe_file, traversal_file]))
|
|
1260
|
+
|
|
1261
|
+
self.plugin.on_files(mock_files, config=mock_config)
|
|
1262
|
+
|
|
1263
|
+
assert "safe.md" in self.plugin.file_map
|
|
1264
|
+
assert "secret.md" not in self.plugin.file_map
|
|
1265
|
+
assert self.plugin.stats["files_ignored"] == 1
|
|
1266
|
+
|
|
1267
|
+
# ------------------------------------------------------------------
|
|
1268
|
+
# Security fix: URL scheme allowlist
|
|
1269
|
+
# ------------------------------------------------------------------
|
|
1270
|
+
|
|
1271
|
+
def test_dangerous_url_schemes_unchanged(self):
|
|
1272
|
+
"""Links with dangerous URL schemes must be returned untouched."""
|
|
1273
|
+
page = self.create_mock_page("docs/index.md")
|
|
1274
|
+
|
|
1275
|
+
dangerous = [
|
|
1276
|
+
"[XSS](javascript:alert(1))",
|
|
1277
|
+
"[Data](data:text/html,<h1>test</h1>)",
|
|
1278
|
+
"[VB](vbscript:msgbox('xss'))",
|
|
1279
|
+
"[Blob](blob:https://example.com/some-uuid)",
|
|
1280
|
+
]
|
|
1281
|
+
|
|
1282
|
+
for markdown in dangerous:
|
|
1283
|
+
result = self.plugin._process_links(markdown, page)
|
|
1284
|
+
assert result == markdown, f"Expected unchanged: {markdown}"
|
|
1285
|
+
|
|
1286
|
+
def test_file_url_unchanged(self):
|
|
1287
|
+
"""file: URLs must be passed through unchanged, not treated as filenames."""
|
|
1288
|
+
# Even if a matching basename happened to be indexed, the scheme must win.
|
|
1289
|
+
self.plugin.file_map = {"passwd": "/etc/passwd"}
|
|
1290
|
+
page = self.create_mock_page("docs/index.md")
|
|
1291
|
+
markdown = "[Secret](file:///etc/passwd)"
|
|
1292
|
+
|
|
1293
|
+
result = self.plugin._process_links(markdown, page)
|
|
1294
|
+
assert result == markdown
|
|
1295
|
+
|
|
1296
|
+
# ------------------------------------------------------------------
|
|
1297
|
+
# Security fix: empty string in exclude_dirs
|
|
1298
|
+
# ------------------------------------------------------------------
|
|
1299
|
+
|
|
1300
|
+
def test_exclude_dirs_empty_string_does_not_exclude_all(self):
|
|
1301
|
+
"""An empty string entry in exclude_dirs must not silently exclude every file."""
|
|
1302
|
+
self.plugin.config["exclude_dirs"] = [""]
|
|
1303
|
+
|
|
1304
|
+
mock_config = MagicMock()
|
|
1305
|
+
mock_files = MagicMock(spec=Files)
|
|
1306
|
+
|
|
1307
|
+
file1 = self.create_mock_file("docs/page.md")
|
|
1308
|
+
file2 = self.create_mock_file("docs/another.md")
|
|
1309
|
+
|
|
1310
|
+
mock_files.__iter__ = MagicMock(return_value=iter([file1, file2]))
|
|
1311
|
+
|
|
1312
|
+
self.plugin.on_files(mock_files, config=mock_config)
|
|
1313
|
+
|
|
1314
|
+
assert "page.md" in self.plugin.file_map
|
|
1315
|
+
assert "another.md" in self.plugin.file_map
|
|
1316
|
+
assert self.plugin.stats["files_indexed"] == 2
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|