Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions yarrharr/sanitize.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright © 2017, 2018, 2019, 2020, 2022 Tom Most <twm@freecog.net>
# Copyright © 2017, 2018, 2019, 2020, 2022, 2025 Tom Most <twm@freecog.net>
#
# 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
Expand All @@ -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 <wbr> in the
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -446,6 +453,22 @@ def _extract_title_text(source):
}


def _adjust_srcset(source):
"""
Reject a ``srcset`` attribute contaning a width descriptor like
``<img srcset="/foo.png 100w">``.
"""
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")
Expand Down Expand Up @@ -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 ]+
(?<!,)
)
(
# Width descriptor like "1234w"
# https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#non-negative-integers
[\t\n\f\r ]+
\d+w
|
# Pixel density descriptor like "2.0x"
# https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number
[\t\n\f\r ]+
\d+(?:\.\d+)?(?:[eE][-+]?\d+)?x
|
)
[\t\n\f\r ]*
(?:,|\Z)
""",
re.VERBOSE | re.ASCII,
)


def srcset_candidates(value: str) -> 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
80 changes: 79 additions & 1 deletion yarrharr/tests/test_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<svg width=1em height=1em class=icon><use href=#icon-video></use></svg>"

Expand Down Expand Up @@ -301,6 +301,22 @@ def test_img_title_to_aside(self):
html = '<img title="blah blah">'
self.assertEqual('<img title="blah blah"><aside class=title-text>blah blah</aside>', sanitize_html(html))

def test_img_srcset_pixel_density(self):
"""
A ``srcset`` containing pixel density descriptors passes through.
"""
html = '<img srcset="/foo.jpg, /foo.2x.jpg 2x">'
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 = '<img srcset="/foo.100.jpg 100w, /foo.200.jpg 200w" sizes="auto" src="/foo.jpg">'
self.assertEqual('<img src="/foo.jpg">', sanitize_html(html))

def test_a_attrs(self):
"""
``<a>`` tags are given ``rel`` and ``target`` attributes.
Expand Down Expand Up @@ -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")
Expand Down