diff --git a/yarrharr/sanitize.py b/yarrharr/sanitize.py index 0ad6c75d..1ee4f582 100644 --- a/yarrharr/sanitize.py +++ b/yarrharr/sanitize.py @@ -1,4 +1,4 @@ -# Copyright © 2017, 2018, 2019, 2020, 2022 Tom Most +# Copyright © 2017, 2018, 2019, 2020, 2022, 2025 Tom Most # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,7 +23,7 @@ from html5lib.filters.base import Filter as BaseFilter from hyperlink import DecodedURL, EncodedURL -REVISION = 8 +REVISION = 9 # Local patch implementing https://github.com/html5lib/html5lib-python/pull/395 # since html5lib-python is unmaintained. This pairs with allowing in the @@ -150,6 +150,7 @@ def sanitize_html(html: str) -> str: source = _elide(source) source = _ReplaceYoutubeEmbedFilter(source) source = _extract_title_text(source) + source = _adjust_srcset(source) source = _adjust_links(source) source = _video_attrs(source) source = _wp_smileys(source) @@ -168,6 +169,12 @@ def sanitize_html(html: str) -> str: ), # https://github.com/html5lib/html5lib-python/pull/395 ] ), + allowed_attributes=sanitizer.allowed_attributes + | frozenset( + [ + (None, "srcset"), + ] + ), ) return serializer.render(source) @@ -446,6 +453,22 @@ def _extract_title_text(source): } +def _adjust_srcset(source): + """ + Reject a ``srcset`` attribute contaning a width descriptor like + ````. + """ + html_ns = namespaces["html"] + srcset_attr = (None, "srcset") + for token in source: + if token["type"] == "EmptyTag" and token["name"] == "img" and token["namespace"] == html_ns and token["data"].get(srcset_attr) is not None: + for url, desc in srcset_candidates(token["data"][srcset_attr]): + if desc.endswith("w"): + del token["data"][srcset_attr] + break + yield token + + def _adjust_links(source): html_ns = namespaces["html"] href_attr = (None, "href") @@ -500,3 +523,53 @@ def _wp_smileys(source): yield token else: yield token + + +# Matches image candidate strings within a srcset attribute value as +# described in https://html.spec.whatwg.org/multipage/images.html#srcset-attributes +_srcset_candidate = re.compile( + r""" + # ASCII whitespace: https://infra.spec.whatwg.org/#ascii-whitespace + [\t\n\f\r ]* + ( + # URL that doesn't start or end with a comma + (?!,) + [^\t\n\f\r ]+ + (? list[tuple[str, str]]: + """ + Split a ``srcset`` attribute value into candidates: + + >>> srcset_candidates("/foo.jpg, /foo.2x.jpg 2x") + [("/foo.jpg", ""), ("/foo.2x.jpg", "2x")] + + This doesn't validate the URLs, nor check for duplicate or conflicting + descriptors. It returns an empty list when parsing fails. + """ + pos = 0 + candidates = [] + while m := _srcset_candidate.match(value, pos): + desc = m[2].strip("\t\n\f\r ") + candidates.append((m[1], desc)) + pos = m.end(0) + return candidates diff --git a/yarrharr/tests/test_sanitize.py b/yarrharr/tests/test_sanitize.py index f9328274..a0c366c4 100644 --- a/yarrharr/tests/test_sanitize.py +++ b/yarrharr/tests/test_sanitize.py @@ -18,7 +18,7 @@ import html5lib -from ..sanitize import html_to_text, sanitize_html +from ..sanitize import html_to_text, sanitize_html, srcset_candidates VIDEO_ICON = "" @@ -301,6 +301,22 @@ def test_img_title_to_aside(self): html = '' self.assertEqual('', sanitize_html(html)) + def test_img_srcset_pixel_density(self): + """ + A ``srcset`` containing pixel density descriptors passes through. + """ + html = '' + self.assertEqual(html, sanitize_html(html)) + + def test_img_srcset_width(self): + """ + Presence of a ``srcset`` width descriptor causes the attribute to be dropped + because width descriptors require a matching ``sizes`` attribute which is CSS + (so complicated to sanitize) and may be coupled to the source page's layout. + """ + html = '' + self.assertEqual('', sanitize_html(html)) + def test_a_attrs(self): """ ```` tags are given ``rel`` and ``target`` attributes. @@ -359,6 +375,68 @@ def test_summary_allowed(self): ) +class SrcsetCandidatesTests(unittest.TestCase): + maxDiff = None + + def test_empty(self): + self.assertEqual([], srcset_candidates("")) + self.assertEqual([], srcset_candidates(" \n")) + + def test_default(self): + self.assertEqual( + [("/1x.jpg", "")], + srcset_candidates("/1x.jpg"), + ) + + def test_x_one(self): + self.assertEqual( + [("/1x.jpg", "1x")], + srcset_candidates("/1x.jpg 1x"), + ) + + def test_x_two(self): + self.assertEqual( + [("/1x.jpg", "1x"), ("/2x.jpg", "2.0x")], + srcset_candidates("/1x.jpg 1x,/2x.jpg\t2.0x"), + ) + + def test_x_three(self): + self.assertEqual( + [("/1x.jpg", ""), ("/2x.jpg", "2x"), ("/3x.jpg", "3x")], + srcset_candidates("/1x.jpg, /2x.jpg 2x , /3x.jpg 3x "), + ) + + def test_x_floats(self): + """ + A pixel density descriptor allows all the valid float formats. + """ + for pd in ["1x", "1.0x", "9.5x", "36x", "39.95x", "100x", "1e1x", "2E2x"]: + self.assertEqual([("/foo.jpg", pd)], srcset_candidates("/foo.jpg " + pd)) + + def test_url_comma(self): + """A URL containing a comma is not broken.""" + self.assertEqual( + [("/,.jpg", "6x"), ("/,,,,.webp", "1e100x")], + srcset_candidates(" /,.jpg 6x,\n /,,,,.webp \t1e100x"), + ) + + def test_one_w(self): + self.assertEqual( + [("/a.png", "600w")], + srcset_candidates("/a.png 600w"), + ) + + def test_two_w(self): + self.assertEqual( + [("a.jpg", "123w"), ("b.jpg", "1234w")], + srcset_candidates("a.jpg 123w, b.jpg 1234w"), + ) + + def test_invalid(self): + for pd in ["1.5w", "9000X", "-23w", "-60x"]: + self.assertEqual([], srcset_candidates("/x.gif " + pd)) + + def print_tokens(html): tree = html5lib.parseFragment(html) w = html5lib.getTreeWalker("etree")