diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 40ce721a..2ca433a4 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -15,9 +15,9 @@ jobs: - macos-latest - windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/CHANGES.md b/CHANGES.md index 5b2b9528..dcfaf393 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ - [pull #704] Fix XSS from smuggling spans into image attributes (#702, #703) - [pull #710] Add emoji support (#709) - [pull #713] Fix `header-ids` extra generating duplicate ids when a suffixed id collides with another header (#661) +- [pull #705] XSS fixes in links, images, and more ## python-markdown2 2.5.5 diff --git a/lib/markdown2.py b/lib/markdown2.py index e9025aa3..86b5d588 100755 --- a/lib/markdown2.py +++ b/lib/markdown2.py @@ -881,6 +881,10 @@ def _detab(self, text: str) -> str: output.append(self._detab_line(line)) return '\n'.join(output) + # https://developer.mozilla.org/en-US/docs/Glossary/Void_element + # technically "self closing tags" (eg:
) are not real HTML but noone cares + _void_tags = 'area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr' + # I broke out the html5 tags here and add them to _block_tags_a and # _block_tags_b. This way html5 tags are easy to keep track of. _html5tags = '|address|article|aside|canvas|figcaption|figure|footer|header|main|nav|section|video' @@ -925,6 +929,7 @@ def _detab(self, text: str) -> str: _html_markdown_attr_re = re.compile( # markdown attr, with optional assignment to true, must be followed by whitespace/boundary/closing tag chars r'''\s+markdown(?:="1"|='1'|=1)?(?![^\s/>\b])''') + def _hash_html_block_sub( self, match: Union[re.Match[str], str], @@ -1123,16 +1128,21 @@ def _strict_tag_block_sub( block += chunk if is_markup: - if chunk.startswith('%s bool: + if re.match(self._void_tags, tag_name): + return True + # check if number of open tags == number of close tags if len(re.findall('<%s(?:.*?)>' % tag_name, text)) != text.count('' % tag_name): return False @@ -1155,6 +1168,29 @@ def _tag_is_closed(self, tag_name: str, text: str) -> bool: open_index = text.find(f'<{tag_name}') return open_index != -1 and close_index != -1 and open_index < close_index + def _tag_imbalance(self, tag_name: str, text: str) -> int: + ''' + Find imbalanced HTML tags in some text + + Args: + tag_name: the name of the tag (eg: "ul") + text: the text to search + + Returns: + 0 for balanced tags, positive int for more opening tags than closing, negative int for + more closing tags than opening + ''' + if re.match(self._void_tags, tag_name): + return 0 + + count = 0 + for tag in re.finditer(r'<(/)?%s\b>?' % tag_name, text): + if tag.group(1): + count -= 1 + else: + count += 1 + return count + @mark_stage(Stage.LINK_DEFS) def _strip_link_definitions(self, text: str) -> str: # Strips link definitions from text, stores the URLs and titles in @@ -1440,13 +1476,13 @@ def _unhash_html_spans(self, text: str, spans=True, code=False) -> str: ''' orig = '' while text != orig: + orig = text if spans: for key, sanitized in list(self.html_spans.items()): text = text.replace(key, sanitized) if code: for code, key in list(self._code_table.items()): text = text.replace(key, code) - orig = text return text def _sanitize_html(self, s: str) -> str: @@ -1537,6 +1573,12 @@ def _protect_url(self, url: str) -> str: mime = data_url.group('mime') or '' if mime.startswith('image/') and data_url.group('token') == ';base64': charset='base64' + else: + url = ( + self._unhash_html_spans(url, code=True) + .replace('*', self._escape_table['*']) + .replace('_', self._escape_table['_']) + ) url = _html_escape_url(url, safe_mode=self.safe_mode, charset=charset) key = _hash_text(url) self._escape_table[url] = key @@ -1556,8 +1598,10 @@ def _safe_href(self): safe = r'-\w' # omitted ['"<>] for XSS reasons less_safe = r'#/\.!#$%&\(\)\+,/:;=\?@\[\]^`\{\}\|~' + # html encoded colon in a URL still functions as a normal colon, so need to detect those + protocol_seperators = [':', ':', ':', ':'] # dot seperated hostname, optional port number, not followed by protocol seperator - domain = r'(?:[{}]+(?:\.[{}]+)*)(?:(? str: _auto_link_re = re.compile(r'<((https?|ftp):[^\'">\s]+)>', re.I) def _auto_link_sub(self, match: re.Match[str]) -> str: g1 = match.group(1) - return '{}'.format(self._protect_url(g1), g1) + return '{}'.format(self._protect_url(g1), _html_escape_url(g1)) _auto_email_link_re = re.compile(r""" < @@ -3228,6 +3272,15 @@ def run(self, text: str): link_text = self.md._hash_html_spans(link_text) link_text = self.md._unhash_html_spans(link_text) + # check that this link is not inside an autolink + if any( + autolink.start() < start_idx < autolink.end() + or autolink.start() < p < autolink.end() + for autolink in self.md._auto_link_re.finditer(text) + ): + curr_pos = start_idx + 1 + continue + # Possibly a footnote ref? if "footnotes" in self.md.extras and link_text.startswith("^"): normed_id = re.sub(r'\W', '-', link_text[1:]) @@ -3261,7 +3314,6 @@ def run(self, text: str): continue text, url, title, url_end_idx = parsed - url = self.md._unhash_html_spans(url, code=True) # reference anchor or reference img else: if not self.options.get('ref', True): @@ -3280,13 +3332,6 @@ def run(self, text: str): curr_pos = p continue - # -- Encode and hash the URL and title to avoid conflicts with italics/bold - - url = ( - url - .replace('*', self.md._escape_table['*']) - .replace('_', self.md._escape_table['_']) - ) if title: if self.md.safe_mode: # expose span contents for escaping - fix #691, #703 @@ -3296,6 +3341,8 @@ def run(self, text: str): .replace('*', self.md._escape_table['*']) .replace('_', self.md._escape_table['_']) ) + if self.md.safe_mode: + title = self.md._hash_span(title) title_str = f' title="{title}"' else: title_str = '' diff --git a/test/test.py b/test/test.py index 2874ccc6..a33a338f 100755 --- a/test/test.py +++ b/test/test.py @@ -47,7 +47,7 @@ def setup(): if version >= (2, 14, 0): tag = "pygments<2.14" else: - tag = "pygments>=2.14" + tag = "pygments>=2.21" warnings.append("skipping {} tests (pygments {} found)".format(tag, mod.__version__)) default_tags.append("-%s" % tag) diff --git a/test/tm-cases/admonitions_with_fenced_code_blocks.html b/test/tm-cases/admonitions_with_fenced_code_blocks.html index 7428c571..8dbca902 100644 --- a/test/tm-cases/admonitions_with_fenced_code_blocks.html +++ b/test/tm-cases/admonitions_with_fenced_code_blocks.html @@ -2,7 +2,7 @@ note

Admonitions are able to contain fenced code blocks

-
print('like so')
+    
print('like so')
     
@@ -10,18 +10,18 @@