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.
Files changed (20) hide show
  1. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.github/workflows/publish.yml +5 -8
  2. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.gitignore +4 -0
  3. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/CHANGELOG.md +23 -0
  4. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/PKG-INFO +1 -1
  5. mkdocs_easylinks_plugin-0.2.3/mkdocs_easylinks/__init__.py +8 -0
  6. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks/plugin.py +99 -22
  7. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/PKG-INFO +1 -1
  8. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/pyproject.toml +1 -1
  9. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/tests/test_plugin.py +162 -0
  10. mkdocs_easylinks_plugin-0.2.1/mkdocs_easylinks/__init__.py +0 -3
  11. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/.github/workflows/test.yml +0 -0
  12. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/LICENSE +0 -0
  13. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/README.md +0 -0
  14. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/SOURCES.txt +0 -0
  15. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/dependency_links.txt +0 -0
  16. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/entry_points.txt +0 -0
  17. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/requires.txt +0 -0
  18. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/mkdocs_easylinks_plugin.egg-info/top_level.txt +0 -0
  19. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/setup.cfg +0 -0
  20. {mkdocs_easylinks_plugin-0.2.1 → mkdocs_easylinks_plugin-0.2.3}/tests/__init__.py +0 -0
@@ -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
- env:
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
@@ -42,3 +42,7 @@ ENV/
42
42
  # OS
43
43
  .DS_Store
44
44
  Thumbs.db
45
+
46
+ # Local Docker testing (not for remote repo)
47
+ Dockerfile
48
+ requirements.txt
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-easylinks-plugin
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: An MkDocs plugin that allows linking to files by filename only
5
5
  Author: Daniel Ferguson
6
6
  License-Expression: MIT
@@ -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("/") + "/" for d in self.config["exclude_dirs"]
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 '{filename}' found in multiple locations:\n"
101
- + "\n".join(f" - {path}" for path in paths)
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 if it's an external link, anchor, or absolute path
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 (':' in link_url and not link_url.startswith('file:'))):
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
- self.stats["images_processed"] += 1
221
+ stats["images_processed"] += 1
152
222
  else:
153
- self.stats["links_processed"] += 1
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 self.config["warn_on_ambiguous"] and link_url in self.ambiguous_files:
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"'{page.file.src_path}': exists at {all_paths}. "
163
- f"Using '{resolved_path}'."
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
- self.stats["images_resolved"] += 1
239
+ stats["images_resolved"] += 1
169
240
  else:
170
- self.stats["links_resolved"] += 1
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
- relative_path = self._get_relative_path(page.file.src_path, resolved_path)
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
- self.stats["images_unresolved"] += 1
258
+ stats["images_unresolved"] += 1
183
259
  else:
184
- self.stats["links_unresolved"] += 1
260
+ stats["links_unresolved"] += 1
185
261
 
186
- if self.config["warn_on_missing"]:
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 '{page.file.src_path}'"
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
- for placeholder, original in protected_blocks.items():
233
- markdown = markdown.replace(placeholder, original)
234
- return markdown
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."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-easylinks-plugin
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: An MkDocs plugin that allows linking to files by filename only
5
5
  Author: Daniel Ferguson
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mkdocs-easylinks-plugin"
7
- version = "0.2.1"
7
+ version = "0.2.3"
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"
@@ -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
@@ -1,3 +0,0 @@
1
- """MkDocs EasyLinks Plugin - Simplified cross-referencing by filename."""
2
-
3
- __version__ = "0.1.4"