Skip to content

Commit c8eb5cc

Browse files
committed
fix: escape user-controlled URLs in ImageFrame and Video embeddings
The |src= option (and Video |width=) were read from options_raw/args_raw and interpolated into HTML attributes unescaped, allowing stored XSS via attribute breakout (e.g. src=http://x" onerror="alert(1)). Route src/href through mistune's escape_url(safe_url(...)) to reject harmful schemes and percent-encode the value, and HTML-escape width. Adds regression tests for both embeddings.
1 parent 99e4d81 commit c8eb5cc

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

otterwiki/renderer_embeddings.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ def _style_attr(styles):
3434
return f' style="{mistune.escape(";".join(styles))}"'
3535

3636

37+
# a bare renderer instance, only used for its safe_url() helper below
38+
_url_safety = mistune.HTMLRenderer()
39+
40+
41+
def _safe_attr_url(url):
42+
"""Return *url* safe to interpolate into an HTML src/href attribute.
43+
44+
Harmful protocols (``javascript:``, ``data:``, ...) are neutralised and
45+
the value is escaped so a crafted value (e.g. an ImageFrame/Video
46+
``|src=`` read from ``options_raw``) cannot break out of the attribute
47+
and inject event handlers. Mirrors OtterwikiMdRenderer's handling of
48+
link and image URLs.
49+
"""
50+
return mistune.escape_url(_url_safety.safe_url(url))
51+
52+
3753
class DatatableEmbedding:
3854
@hookimpl
3955
def info(self):
@@ -575,9 +591,10 @@ def embedding_render(
575591
)
576592
alt = alt or mistune.escape(att_filename)
577593
img_url = attachment.get_url()
594+
safe_img_url = _safe_attr_url(img_url)
578595
content += (
579-
f'<a href="{img_url}" target="_blank">'
580-
f'<img src="{img_url}" alt="{alt}" style="width:100%">'
596+
f'<a href="{safe_img_url}" target="_blank">'
597+
f'<img src="{safe_img_url}" alt="{alt}" style="width:100%">'
581598
f'</a>'
582599
)
583600
else:
@@ -681,7 +698,9 @@ def embedding_render(
681698
):
682699
if embedding.lower() != "video":
683700
return None
684-
width = args.options_raw.get("width", "100%")
701+
# width is read raw and interpolated into an attribute; escape it so
702+
# a crafted value cannot break out and inject event handlers
703+
width = mistune.escape(args.options_raw.get("width", "100%"))
685704
flags = []
686705
if args.get_flag("controls", True):
687706
flags.append("controls")
@@ -746,7 +765,7 @@ def embedding_render(
746765
t = " type=\"video/mp4\""
747766
elif s.endswith(".ogg"):
748767
t = " type=\"video/ogg\""
749-
video_sources += f'<source src="{s}"{t}>\n'
768+
video_sources += f'<source src="{_safe_attr_url(s)}"{t}>\n'
750769

751770
if video_sources:
752771
html_parts.append(

tests/test_embeddings.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,58 @@ def test_embedding_style_no_xss():
184184
assert "&quot;" in html
185185

186186

187+
def _assert_no_event_handlers(html):
188+
"""Fail if any rendered tag carries an on* event-handler attribute."""
189+
soup = BeautifulSoup(html, "html.parser")
190+
for tag in soup.find_all(True):
191+
for attr in tag.attrs:
192+
assert not attr.lower().startswith(
193+
"on"
194+
), f"event handler {attr!r} injected on <{tag.name}>: {html!r}"
195+
196+
197+
def test_imageframe_src_no_xss():
198+
"""A crafted |src= must not break out of the attribute and inject an
199+
event handler (see the ImageFrame/Video XSS report)."""
200+
payloads = [
201+
'http://x" autofocus onfocus="alert(document.domain)',
202+
'https://e.com/x.png" onerror="alert(1)',
203+
]
204+
for payload in payloads:
205+
md = f"{{{{ImageFrame\n|src={payload}\n}}}}\n"
206+
html, _, _ = render.markdown(md)
207+
_assert_no_event_handlers(html)
208+
# the crafted double-quote must not survive as a live attribute
209+
# delimiter (it is neutralised via escaping/percent-encoding)
210+
assert 'onfocus="' not in html
211+
assert 'onerror="' not in html
212+
213+
214+
def test_video_src_no_xss():
215+
"""A crafted Video src (option or positional) must not inject an event
216+
handler onto the <source> element."""
217+
payloads = [
218+
'{{Video\n|src=https://e.com/v.mp4" onerror="alert(1)\n}}\n',
219+
'{{Video\nhttps://e.com/v.mp4" onerror="alert(1)\n}}\n',
220+
]
221+
for md in payloads:
222+
html, _, _ = render.markdown(md)
223+
_assert_no_event_handlers(html)
224+
225+
226+
def test_video_width_no_xss():
227+
"""A crafted |width= must not break out of the attribute — on the file
228+
<video> element or on the YouTube <iframe>."""
229+
payloads = [
230+
'{{Video\n|width=100" onmouseover="alert(1)\n/x.mp4\n}}\n',
231+
'{{Video\n|width=100" onmouseover="alert(1)\n'
232+
'https://youtu.be/dQw4w9WgXcQ\n}}\n',
233+
]
234+
for md in payloads:
235+
html, _, _ = render.markdown(md)
236+
_assert_no_event_handlers(html)
237+
238+
187239
def test_imageframe_alias():
188240
md = """
189241
{{Image Frame

0 commit comments

Comments
 (0)