diff --git a/scripts/html_safe.py b/scripts/html_safe.py
index 40768a0..792f083 100644
--- a/scripts/html_safe.py
+++ b/scripts/html_safe.py
@@ -27,7 +27,7 @@
"""
import html
-from urllib.parse import urlparse
+from urllib.parse import urlparse, quote
def esc(value):
@@ -57,6 +57,45 @@ def safe_url(url, text=None, target=None): # pylint: disable=unused-argumen
return esc(text if text is not None else url)
+def _is_report_relative(path, allow_parent=False):
+ """True when ``path`` names something reachable from the report folder.
+
+ Rejects a URL scheme, a protocol-relative ``//host`` and an absolute path, so a
+ crafted media name cannot turn a report cell into a remote fetch. ``..`` is
+ rejected too unless ``allow_parent`` is set: media_to_html() genuinely emits
+ ``../data/...`` to reach the extraction folder next to the report, and that is a
+ deliberate part of the report layout rather than an escape.
+ """
+ if path.startswith(('/', '\\')):
+ return False
+ normalized = path.replace('\\', '/')
+ if normalized.startswith('//'):
+ return False
+ if not allow_parent and '..' in normalized.split('/'):
+ return False
+ try:
+ if urlparse(path).scheme:
+ return False
+ except ValueError:
+ return False
+ return True
+
+
+def safe_local_path(path, allow_parent=False):
+ """Percent-encode a report-relative path for use in an ``href``/``src`` attribute.
+
+ Returns ``''`` when the path is not report-relative, so a crafted media filename
+ can neither point the report at a remote host nor reach outside the report folder.
+ The encoded result is HTML-escaped as well, so it is safe inside a quoted
+ attribute. Use this for the attribute value; use safe_local_link() when you want
+ the whole anchor.
+ """
+ path = '' if path is None else str(path).strip()
+ if not path or not _is_report_relative(path, allow_parent):
+ return ''
+ return esc(quote(path, safe='/.'))
+
+
def safe_local_link(path, text=None):
"""Build an ```` to a file inside the report folder.
@@ -67,14 +106,7 @@ def safe_local_link(path, text=None):
"""
path = '' if path is None else str(path).strip()
label = esc(text if text is not None else path)
- if not path:
- return label
- if path.startswith(('/', '\\', '//')) or '..' in path.replace('\\', '/').split('/'):
- return label
- try:
- if urlparse(path).scheme:
- return label
- except ValueError:
+ if not path or not _is_report_relative(path):
return label
return f'{label}'
diff --git a/scripts/ilapfuncs.py b/scripts/ilapfuncs.py
index 70aebdb..dd44b27 100755
--- a/scripts/ilapfuncs.py
+++ b/scripts/ilapfuncs.py
@@ -45,6 +45,7 @@
# LEAPP version unique imports
from typing import Pattern
+from scripts.html_safe import esc, safe_local_path
from scripts.lavafuncs import lava_process_artifact, lava_insert_sqlite_data, lava_get_media_item, \
lava_insert_sqlite_media_item, lava_insert_sqlite_media_references, lava_get_media_references, \
lava_get_full_media_info
@@ -353,20 +354,25 @@ def relative_paths(source):
filename = Path(source).name
return f"media/{filename}"
- filename = Path(media_path).name
- media_path = quote(relative_paths(media_path))
+ # The media name comes from the evidence, so every place it is emitted is
+ # escaped: percent-encoded in src/href by safe_local_path(), which also refuses a
+ # target that would leave the report folder, and HTML-escaped in title= and in the
+ # fallback link text. Before this, a crafted attachment filename broke out of the
+ # title attribute and ran in the examiner's report (CWE-79).
+ filename = esc(Path(media_path).name)
+ media_path = safe_local_path(relative_paths(media_path))
- if mimetype == None:
+ if mimetype is None:
mimetype = ''
if 'video' in mimetype:
thumb = f''
elif 'image' in mimetype:
- image_style = style if style else "max-height:300px; max-width:400px;"
- thumb = f''
+ image_style = esc(style) if style else "max-height:300px; max-width:400px;"
+ thumb = f''
elif 'audio' in mimetype:
thumb = f''
else:
- thumb = f' Link to {filename} file>'
+ thumb = f' Link to {filename} file'
return thumb
def get_data_list_with_media(media_header_info, data_list):
@@ -913,17 +919,23 @@ def relative_paths(source, splitter):
source = relative_paths(str(source), splitter)
mimetype = guess_mime(match)
- if mimetype == None:
+ if mimetype is None:
mimetype = ''
+ # allow_parent: relative_paths() above deliberately emits ../data/... to reach
+ # the extraction folder beside the report. The evidence filename in the
+ # fallback link text is escaped -- it used to be interpolated raw.
+ source = safe_local_path(source, allow_parent=True)
+ filename = esc(filename)
+
if 'video' in mimetype:
thumb = f''
elif 'image' in mimetype:
- thumb = f''
+ thumb = f''
elif 'audio' in mimetype:
thumb = f''
else:
- thumb = f' Link to {filename} file>'
+ thumb = f' Link to {filename} file'
return thumb