markdown: Switch to directly URL-escaping CSS URLs.

soupsieve is a heavy-weight dependency, and Tornado pulls it in by way
of markdown rendering; since we are only using it for a very simple
process, perform that manually.

Per CSS spec[^1]:

> In quoted <string> url()s, only newlines and the character used to
> quote the string need to be escaped.

[^1]: https://drafts.csswg.org/css-values/#urls
This commit is contained in:
Alex Vandiver 2024-04-15 21:37:58 +00:00 committed by Tim Abbott
parent 1424a2e748
commit 693b959656
5 changed files with 16 additions and 21 deletions

View File

@ -186,9 +186,6 @@ django-cte
# SCIM integration # SCIM integration
django-scim2 django-scim2
# CSS manipulation
soupsieve
# Circuit-breaking for outgoing services # Circuit-breaking for outgoing services
circuitbreaker circuitbreaker

View File

@ -2894,9 +2894,7 @@ sortedcontainers==2.4.0 \
soupsieve==2.5 \ soupsieve==2.5 \
--hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \ --hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \
--hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7 --hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7
# via # via beautifulsoup4
# -r requirements/common.in
# beautifulsoup4
sphinx==7.2.6 \ sphinx==7.2.6 \
--hash=sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560 \ --hash=sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560 \
--hash=sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5 --hash=sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5

View File

@ -2240,9 +2240,7 @@ social-auth-core[azuread,openidconnect,saml]==4.5.3 \
soupsieve==2.5 \ soupsieve==2.5 \
--hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \ --hash=sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690 \
--hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7 --hash=sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7
# via # via beautifulsoup4
# -r requirements/common.in
# beautifulsoup4
sqlalchemy==1.4.52 \ sqlalchemy==1.4.52 \
--hash=sha256:1296f2cdd6db09b98ceb3c93025f0da4835303b8ac46c15c2136e27ee4d18d94 \ --hash=sha256:1296f2cdd6db09b98ceb3c93025f0da4835303b8ac46c15c2136e27ee4d18d94 \
--hash=sha256:1e135fff2e84103bc15c07edd8569612ce317d64bdb391f49ce57124a73f45c5 \ --hash=sha256:1e135fff2e84103bc15c07edd8569612ce317d64bdb391f49ce57124a73f45c5 \

View File

@ -47,7 +47,6 @@ import uri_template
from django.conf import settings from django.conf import settings
from markdown.blockparser import BlockParser from markdown.blockparser import BlockParser
from markdown.extensions import codehilite, nl2br, sane_lists, tables from markdown.extensions import codehilite, nl2br, sane_lists, tables
from soupsieve import escape as css_escape
from tlds import tld_set from tlds import tld_set
from typing_extensions import Self, TypeAlias, override from typing_extensions import Self, TypeAlias, override
@ -690,7 +689,12 @@ class InlineInterestingLinkProcessor(markdown.treeprocessors.Treeprocessor):
img_link = get_camo_url(extracted_data.image) img_link = get_camo_url(extracted_data.image)
img = SubElement(container, "a") img = SubElement(container, "a")
img.set("style", "background-image: url(" + css_escape(img_link) + ")") img.set(
"style",
'background-image: url("'
+ img_link.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\a ")
+ '")',
)
img.set("href", link) img.set("href", link)
img.set("class", "message_embed_image") img.set("class", "message_embed_image")

View File

@ -534,7 +534,7 @@ class PreviewTestCase(ZulipTestCase):
@override_settings(CAMO_URI="") @override_settings(CAMO_URI="")
def test_inline_url_embed_preview(self) -> None: def test_inline_url_embed_preview(self) -> None:
with_preview = '<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(http\\:\\/\\/ia\\.media-imdb\\.com\\/images\\/rock\\.jpg)"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>' with_preview = '<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(&quot;http://ia.media-imdb.com/images/rock.jpg&quot;)"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>'
without_preview = '<p><a href="http://test.org/">http://test.org/</a></p>' without_preview = '<p><a href="http://test.org/">http://test.org/</a></p>'
msg = self._send_message_with_test_org_url(sender=self.example_user("hamlet")) msg = self._send_message_with_test_org_url(sender=self.example_user("hamlet"))
self.assertEqual(msg.rendered_content, with_preview) self.assertEqual(msg.rendered_content, with_preview)
@ -549,13 +549,11 @@ class PreviewTestCase(ZulipTestCase):
self.assertEqual(msg.rendered_content, without_preview) self.assertEqual(msg.rendered_content, without_preview)
def test_inline_url_embed_preview_with_camo(self) -> None: def test_inline_url_embed_preview_with_camo(self) -> None:
camo_url = re.sub( camo_url = get_camo_url("http://ia.media-imdb.com/images/rock.jpg")
r"([^\w-])", r"\\\1", get_camo_url("http://ia.media-imdb.com/images/rock.jpg")
)
with_preview = ( with_preview = (
'<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(' '<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(&quot;'
+ camo_url + camo_url
+ ')"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>' + '&quot;)"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>'
) )
msg = self._send_message_with_test_org_url(sender=self.example_user("hamlet")) msg = self._send_message_with_test_org_url(sender=self.example_user("hamlet"))
self.assertEqual(msg.rendered_content, with_preview) self.assertEqual(msg.rendered_content, with_preview)
@ -575,7 +573,7 @@ class PreviewTestCase(ZulipTestCase):
event = patched.call_args[0][1] event = patched.call_args[0][1]
# Swap the URL out for one with characters that need CSS escaping # Swap the URL out for one with characters that need CSS escaping
html = re.sub(r"rock\.jpg", "rock).jpg", self.open_graph_html) html = re.sub(r"rock\.jpg", r"rock.jpg\\", self.open_graph_html)
self.create_mock_response(url, body=html) self.create_mock_response(url, body=html)
with self.settings(TEST_SUITE=False): with self.settings(TEST_SUITE=False):
with self.assertLogs(level="INFO") as info_logs: with self.assertLogs(level="INFO") as info_logs:
@ -590,7 +588,7 @@ class PreviewTestCase(ZulipTestCase):
'<p><a href="http://test.org/">http://test.org/</a></p>\n' '<p><a href="http://test.org/">http://test.org/</a></p>\n'
'<div class="message_embed"><a class="message_embed_image" href="http://test.org/"' '<div class="message_embed"><a class="message_embed_image" href="http://test.org/"'
' style="background-image:' ' style="background-image:'
' url(http\\:\\/\\/ia\\.media-imdb\\.com\\/images\\/rock\\)\\.jpg)"></a><div' ' url(&quot;http://ia.media-imdb.com/images/rock.jpg\\\\&quot;)"></a><div'
' class="data-container"><div class="message_embed_title"><a href="http://test.org/"' ' class="data-container"><div class="message_embed_title"><a href="http://test.org/"'
' title="The Rock">The Rock</a></div><div class="message_embed_description">Description' ' title="The Rock">The Rock</a></div><div class="message_embed_description">Description'
" text</div></div></div>" " text</div></div></div>"
@ -614,7 +612,7 @@ class PreviewTestCase(ZulipTestCase):
@override_settings(CAMO_URI="") @override_settings(CAMO_URI="")
def test_inline_url_embed_preview_with_relative_image_url(self) -> None: def test_inline_url_embed_preview_with_relative_image_url(self) -> None:
with_preview_relative = '<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(http\\:\\/\\/test\\.org\\/images\\/rock\\.jpg)"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>' with_preview_relative = '<p><a href="http://test.org/">http://test.org/</a></p>\n<div class="message_embed"><a class="message_embed_image" href="http://test.org/" style="background-image: url(&quot;http://test.org/images/rock.jpg&quot;)"></a><div class="data-container"><div class="message_embed_title"><a href="http://test.org/" title="The Rock">The Rock</a></div><div class="message_embed_description">Description text</div></div></div>'
# Try case where the Open Graph image is a relative URL. # Try case where the Open Graph image is a relative URL.
msg = self._send_message_with_test_org_url( msg = self._send_message_with_test_org_url(
sender=self.example_user("prospero"), relative_url=True sender=self.example_user("prospero"), relative_url=True
@ -832,7 +830,7 @@ class PreviewTestCase(ZulipTestCase):
assert msg.rendered_content is not None assert msg.rendered_content is not None
self.assertIn(cached_data.title, msg.rendered_content) self.assertIn(cached_data.title, msg.rendered_content)
assert cached_data.image is not None assert cached_data.image is not None
self.assertIn(re.sub(r"([^\w-])", r"\\\1", cached_data.image), msg.rendered_content) self.assertIn(cached_data.image, msg.rendered_content)
@responses.activate @responses.activate
@override_settings(INLINE_URL_EMBED_PREVIEW=True) @override_settings(INLINE_URL_EMBED_PREVIEW=True)