Repository: Python-Markdown/markdown (python)

Issue:
Extension Fenced Code Blocks breaks HTML formatting on quotes in attributes
The extension **fenced code blocks** (https://python-markdown.github.io/extensions/fenced_code_blocks/#attributes) breaks the HTML formatting when a language, id or class contains a quotation-mark ("). 

https://github.com/Python-Markdown/markdown/blob/master/markdown/extensions/fenced_code.py#L122-L127

The following snippet
````
``` { .">outside}

```
````
will render as
```
<pre><code class="language-">outside">
</code></pre>
```

Even though the users of **Python-Markdown** are responsible for sanitizing / escaping the end-result, this might lead to some unintended behaviour (as seen in https://github.com/netbox-community/netbox/issues/9292).

Pull request:
Ensure fenced code attributes are properly escaped.
Fixes #1247.

Note that the relevant sections of code were also converted to use f-strings.

Patch:
diff --git a/docs/change_log/index.md b/docs/change_log/index.md
index 5320fc7..b9fa97b 100644
--- a/docs/change_log/index.md
+++ b/docs/change_log/index.md
@@ -7,6 +7,7 @@ Python-Markdown Change Log
 
 * Disallow square brackets in reference link ids (#1209).
 * Retain configured `pygments_style` after first code block (#1240).
+* Ensure fenced code attributes are properly escaped (#1247).
 
 Nov 17, 2021: version 3.3.6 (a bug-fix release).
 
diff --git a/markdown/extensions/fenced_code.py b/markdown/extensions/fenced_code.py
index 9be0ca0..409166a 100644
--- a/markdown/extensions/fenced_code.py
+++ b/markdown/extensions/fenced_code.py
@@ -22,6 +22,7 @@ from ..preprocessors import Preprocessor
 from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines
 from .attr_list import get_attrs, AttrListExtension
 from ..util import parseBoolValue
+from ..serializers import _escape_attrib_html
 import re
 
 
@@ -120,30 +121,24 @@ class FencedBlockPreprocessor(Preprocessor):
                 else:
                     id_attr = lang_attr = class_attr = kv_pairs = ''
                     if lang:
-                        lang_attr = ' class="{}{}"'.format(self.config.get('lang_prefix', 'language-'), lang)
+                        prefix = self.config.get('lang_prefix', 'language-')
+                        lang_attr = f' class="{prefix}{_escape_attrib_html(lang)}"'
                     if classes:
-                        class_attr = ' class="{}"'.format(' '.join(classes))
+                        class_attr = f' class="{_escape_attrib_html(" ".join(classes))}"'
                     if id:
-                        id_attr = ' id="{}"'.format(id)
+                        id_attr = f' id="{_escape_attrib_html(id)}"'
                     if self.use_attr_list and config and not config.get('use_pygments', False):
                         # Only assign key/value pairs to code element if attr_list ext is enabled, key/value pairs
                         # were defined on the code block, and the `use_pygments` key was not set to True. The
                         # `use_pygments` key could be either set to False or not defined. It is omitted from output.
-                        kv_pairs = ' ' + ' '.join(
-                            '{k}="{v}"'.format(k=k, v=v) for k, v in config.items() if k != 'use_pygments'
+                        kv_pairs = ''.join(
+                            f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments'
                         )
-                    code = '<pre{id}{cls}><code{lang}{kv}>{code}</code></pre>'.format(
-                        id=id_attr,
-                        cls=class_attr,
-                        lang=lang_attr,
-                        kv=kv_pairs,
-                        code=self._escape(m.group('code'))
-                    )
+                    code = self._escape(m.group('code'))
+                    code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>'
 
                 placeholder = self.md.htmlStash.store(code)
-                text = '{}\n{}\n{}'.format(text[:m.start()],
-                                           placeholder,
-                                           text[m.end():])
+                text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
             else:
                 break
         return text.split("\n")


Test patch:
diff --git a/tests/test_syntax/extensions/test_fenced_code.py b/tests/test_syntax/extensions/test_fenced_code.py
index 76c8769..2cdde98 100644
--- a/tests/test_syntax/extensions/test_fenced_code.py
+++ b/tests/test_syntax/extensions/test_fenced_code.py
@@ -374,6 +374,24 @@ class TestFencedCode(TestCase):
             extensions=[markdown.extensions.fenced_code.FencedCodeExtension(lang_prefix='lang-')]
         )
 
+    def testFencedCodeEscapedAttrs(self):
+        self.assertMarkdownRenders(
+            self.dedent(
+                '''
+                ``` { ."weird #"foo bar=">baz }
+                # Some python code
+                ```
+                '''
+            ),
+            self.dedent(
+                '''
+                <pre id="&quot;foo"><code class="language-&quot;weird" bar="&quot;&gt;baz"># Some python code
+                </code></pre>
+                '''
+            ),
+            extensions=['fenced_code', 'attr_list']
+        )
+
 
 class TestFencedCodeWithCodehilite(TestCase):