diff --git a/IPython/core/display.py b/IPython/core/display.py index 2af0e6c6418..dbe70d8653c 100644 --- a/IPython/core/display.py +++ b/IPython/core/display.py @@ -1065,7 +1065,7 @@ def _repr_html_(self): if self.alt: alt = ' alt="%s"' % html.escape(self.alt) return ''.format( - url=self.url, + url=html.escape(self.url or ""), width=width, height=height, klass=klass, @@ -1228,7 +1228,7 @@ def _repr_html_(self): url = self.url if self.url is not None else self.filename output = """""".format(url, self.html_attributes, width, height) + """.format(html.escape(url or ""), self.html_attributes, width, height) return output # Embedded videos are base64-encoded. diff --git a/IPython/lib/display.py b/IPython/lib/display.py index 72486198e7f..afe1882cf97 100644 --- a/IPython/lib/display.py +++ b/IPython/lib/display.py @@ -240,7 +240,7 @@ def src_attr(self): return """data:{type};base64,{base64}""".format(type=self.mimetype, base64=data) elif self.url is not None: - return self.url + return html_escape(self.url) else: return "" @@ -252,7 +252,7 @@ def autoplay_attr(self): def element_id_attr(self): if (self.element_id): - return f'id="{self.element_id}"' + return f'id="{html_escape(self.element_id)}"' else: return '' @@ -292,9 +292,9 @@ def _repr_html_(self): else: params = "" return self.iframe.format( - src=self.src, - width=self.width, - height=self.height, + src=html_escape(self.src), + width=html_escape(str(self.width)), + height=html_escape(str(self.height)), params=params, extras=" ".join(self.extras), ) @@ -513,7 +513,12 @@ def __init__(self, self.recursive = recursive def _get_display_formatter( - self, dirname_output_format, fname_output_format, fp_format, fp_cleaner=None + self, + dirname_output_format, + fname_output_format, + fp_format, + fp_cleaner=None, + escape_names=False, ): """generate built-in formatter function @@ -531,7 +536,11 @@ def _get_display_formatter( fp_format: string to use for formatting filepaths, must contain exactly two "%s" and the dirname will be substituted for the first and fname will be substituted for the second + escape_names: whether directory and file names must be HTML-escaped + before being substituted, as they are for the notebook formatter """ + escape = html_escape if escape_names else str + def f(dirname, fnames, included_suffixes=None): result = [] # begin by figuring out which filenames, if any, @@ -550,18 +559,18 @@ def f(dirname, fnames, included_suffixes=None): else: # otherwise print the formatted directory name followed by # the formatted filenames - dirname_output_line = dirname_output_format % dirname + dirname_output_line = dirname_output_format % escape(dirname) result.append(dirname_output_line) for fname in display_fnames: - fp = fp_format % (dirname,fname) + fp = fp_format % (escape(dirname), escape(fname)) if fp_cleaner is not None: fp = fp_cleaner(fp) try: # output can include both a filepath and a filename... - fname_output_line = fname_output_format % (fp, fname) + fname_output_line = fname_output_format % (fp, escape(fname)) except TypeError: # ... or just a single filepath - fname_output_line = fname_output_format % fname + fname_output_line = fname_output_format % escape(fname) result.append(fname_output_line) return result return f @@ -586,10 +595,13 @@ def fp_cleaner(fp): else: fp_cleaner = None - return self._get_display_formatter(dirname_output_format, - fname_output_format, - fp_format, - fp_cleaner) + return self._get_display_formatter( + dirname_output_format, + fname_output_format, + fp_format, + fp_cleaner, + escape_names=True, + ) def _get_terminal_display_formatter(self, spacer=" "): diff --git a/tests/test_display.py b/tests/test_display.py index b262c515c27..ad4acb0670f 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -188,11 +188,38 @@ def test_recursive_FileLinks(): assert len(actual) == 2, actual +def test_escaped_names_FileLinks(): + """FileLinks: html metacharacters in file names are escaped""" + td = mkdtemp() + # links are emitted as href='...', so the apostrophe is what breaks out of + # the attribute; "<" and ">" are not usable as they are invalid on windows + name = "a' onmouseover='alert(1)&.txt" + with open(pjoin(td, name), "w"): + pass + actual = display.FileLinks(td)._repr_html_() + assert "a' onmouseover='alert(1)&.txt" in actual + assert name not in actual + + def test_audio_from_file(): path = pjoin(dirname(__file__), "test.wav") display.Audio(filename=path) +def test_escaped_url_Audio(): + """Audio: quotes in url and element_id do not break out of the attribute""" + audio = display.Audio(url='http://example.com/a.wav" onerror="alert(1)') + assert ( + 'src="http://example.com/a.wav" onerror="alert(1)"' + in audio._repr_html_() + ) + + audio = display.Audio( + url="http://example.com/a.wav", element_id='x" onload="alert(1)' + ) + assert 'id="x" onload="alert(1)"' in audio._repr_html_() + + @skipif_not_numpy def test_audio_from_numpy_array(): test_tone = get_test_tone() diff --git a/tests/test_display_2.py b/tests/test_display_2.py index ceeab561dbc..43dac8fcfda 100644 --- a/tests/test_display_2.py +++ b/tests/test_display_2.py @@ -565,6 +565,36 @@ def test_image_alt_tag(): assert md["alt"] == "an image" +def test_image_url_escaping(): + """Image: a quote in the url does not break out of the src attribute""" + img = display.Image(url='http://example.com/i.png" onerror="alert(1)') + assert ( + '' + == img._repr_html_() + ) + + +def test_video_url_escaping(): + """Video: a quote in the url does not break out of the src attribute""" + v = display.Video('http://example.com/v.mp4" onerror="alert(1)') + assert ( + 'src="http://example.com/v.mp4" onerror="alert(1)"' + in v._repr_html_() + ) + + +def test_iframe_escaping(): + """IFrame: quotes in src, width and height stay inside their attributes""" + html = display.IFrame('http://example.com/?a=1">