From 127607de00a81d01cd50d21dfb43bf7fe382e0b1 Mon Sep 17 00:00:00 2001 From: Brigs Date: Thu, 6 Aug 2026 21:46:47 -0400 Subject: [PATCH] Escape the evidence media name in report media tags Port of the iLEAPP change. html_media_tag() interpolated the media item's name straight into the title= attribute of the it builds, and both it and media_to_html() dropped the raw evidence filename into the fallback anchor text. A crafted attachment filename closed the attribute and ran in the examiner's report (stored XSS, CWE-79). This reaches every media column tool-wide, because the framework adds media columns to the no-escape list itself. safe_local_path() is added to html_safe.py: it percent-encodes a report-relative path for an href/src attribute and returns '' for anything not report-relative, so a crafted media name can neither point the report at a remote host nor climb out of the report folder. _is_report_relative() is factored out of safe_local_link() so both helpers apply one rule. media_to_html() passes allow_parent=True, because its relative_paths() deliberately emits ../data/... to reach the extraction folder beside the report. That case is now permitted explicitly instead of silently. The media name is esc()'d in title= and in the fallback link text, the image style is esc()'d, and the malformed closing the fallback anchor is . Validated: py_compile clean; unit tests OK; PluginLoader loads every plugin; a filename of x" onerror=alert(1) t=".jpg renders escaped with no attribute breakout, and a normal HEIC still renders its thumbnail unchanged. Co-Authored-By: Claude Opus 5 --- scripts/html_safe.py | 50 ++++++++++++++++++++++++++++++++++++-------- scripts/ilapfuncs.py | 30 ++++++++++++++++++-------- 2 files changed, 62 insertions(+), 18 deletions(-) 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