Skip to content

Commit 0b2fedb

Browse files
committed
Resolve relative srcset URLs
1 parent 7e42e1c commit 0b2fedb

5 files changed

Lines changed: 167 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added
2+
-----
3+
4+
* Resolve relative URLs in ``srcset`` attributes and pass through ``srcset`` when sanitizing.

feedparser/sanitizer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ class HTMLSanitizer(BaseHTMLProcessor):
259259
"size",
260260
"span",
261261
"src",
262+
"srcset",
262263
"start",
263264
"step",
264265
"style",

feedparser/urls.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Copyright 2010-2025 Kurt McKee <contactme@kurtmckee.org>
2+
# Copyright 2025 Tom Most <twm@freecog.net>
23
# Copyright 2002-2008 Mark Pilgrim
34
# All rights reserved.
45
#
@@ -116,6 +117,56 @@ def make_safe_absolute_uri(base, rel=None):
116117
return uri
117118

118119

120+
# Matches image candidate strings within a srcset attribute value as
121+
# described in https://html.spec.whatwg.org/multipage/images.html#srcset-attributes
122+
_srcset_candidate = re.compile(
123+
r"""
124+
# ASCII whitespace: https://infra.spec.whatwg.org/#ascii-whitespace
125+
[\t\n\f\r ]*
126+
(
127+
# URL that doesn't start or end with a comma
128+
(?!,)
129+
[^\t\n\f\r ]+
130+
(?<!,)
131+
)
132+
(
133+
# Width descriptor like "1234w"
134+
# https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#non-negative-integers
135+
[\t\n\f\r ]+
136+
\d+w
137+
|
138+
# Pixel density descriptor like "2.0x"
139+
# https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#valid-floating-point-number
140+
[\t\n\f\r ]+
141+
\d+(?:\.\d+)?(?:[eE][-+]?\d+)?x
142+
|
143+
)
144+
[\t\n\f\r ]*
145+
(?:,|\Z)
146+
""",
147+
re.VERBOSE | re.ASCII,
148+
)
149+
150+
151+
def srcset_candidates(value: str) -> list[tuple[str, str]]:
152+
"""
153+
Split a ``srcset`` attribute value into candidates:
154+
155+
>>> srcset_candidates("/foo.jpg, /foo.2x.jpg 2x")
156+
[("/foo.jpg", ""), ("/foo.2x.jpg", "2x")]
157+
158+
This doesn't validate the URLs, nor check for duplicate or conflicting
159+
descriptors. It returns an empty list when parsing fails.
160+
"""
161+
pos = 0
162+
candidates = []
163+
while m := _srcset_candidate.match(value, pos):
164+
desc = m[2].strip("\t\n\f\r ")
165+
candidates.append((m[1], desc))
166+
pos = m.end(0)
167+
return candidates
168+
169+
119170
class RelativeURIResolver(BaseHTMLProcessor):
120171
relative_uris = {
121172
("a", "href"),
@@ -156,11 +207,23 @@ def __init__(self, baseuri, encoding, _type):
156207
def resolve_uri(self, uri):
157208
return make_safe_absolute_uri(self.baseuri, uri.strip())
158209

210+
def resolve_srcset(self, srcset):
211+
candidates = []
212+
for uri, desc in srcset_candidates(srcset):
213+
uri = self.resolve_uri(uri)
214+
if desc:
215+
candidates.append(f"{uri} {desc}")
216+
else:
217+
candidates.append(uri)
218+
return ", ".join(candidates)
219+
159220
def unknown_starttag(self, tag, attrs):
160221
attrs = self.normalize_attrs(attrs)
161222
for i, (key, value) in enumerate(attrs):
162223
if (tag, key) in self.relative_uris:
163224
attrs[i] = (key, self.resolve_uri(value))
225+
elif tag in {"img", "source"} and key == "srcset":
226+
attrs[i] = (key, self.resolve_srcset(value))
164227
super().unknown_starttag(tag, attrs)
165228

166229

tests/test_srcset_candidates.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Copyright 2025 Tom Most <twm@freecog.net>
2+
# All rights reserved.
3+
#
4+
# This file is a part of feedparser.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice,
10+
# this list of conditions and the following disclaimer.
11+
# * Redistributions in binary form must reproduce the above copyright notice,
12+
# this list of conditions and the following disclaimer in the documentation
13+
# and/or other materials provided with the distribution.
14+
#
15+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 'AS IS'
16+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
18+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
19+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
20+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
21+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
22+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
23+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
24+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
25+
# POSSIBILITY OF SUCH DAMAGE.
26+
27+
import pytest
28+
29+
from feedparser.urls import srcset_candidates
30+
31+
32+
def test_empty():
33+
assert srcset_candidates("") == []
34+
assert srcset_candidates(" \n") == []
35+
36+
37+
def test_default():
38+
assert srcset_candidates("/1x.jpg") == [("/1x.jpg", "")]
39+
40+
41+
def test_pixel_density_descriptor_one():
42+
assert srcset_candidates("/1x.jpg 1x") == [("/1x.jpg", "1x")]
43+
44+
45+
def test_pixel_density_descriptor_two():
46+
assert srcset_candidates("/1x.jpg 1x,/2x.jpg\t2.0x") == [
47+
("/1x.jpg", "1x"),
48+
("/2x.jpg", "2.0x"),
49+
]
50+
51+
52+
def test_pixel_density_descriptor_three():
53+
assert srcset_candidates("/1x.jpg, /2x.jpg 2x , /3x.jpg 3x ") == [
54+
("/1x.jpg", ""),
55+
("/2x.jpg", "2x"),
56+
("/3x.jpg", "3x"),
57+
]
58+
59+
60+
@pytest.mark.parametrize(
61+
"pd", ["1x", "1.0x", "9.5x", "36x", "39.95x", "100x", "1e1x", "2E2x"]
62+
)
63+
def test_pixel_density_descriptor_floats(pd):
64+
"""A pixel density descriptor allows all the valid float formats."""
65+
assert [("/foo.jpg", pd)] == srcset_candidates("/foo.jpg " + pd)
66+
67+
68+
def test_url_comma():
69+
"""A URL containing a comma is not broken."""
70+
assert srcset_candidates(" /,.jpg 6x,\n /,,,,.webp \t1e100x") == [
71+
("/,.jpg", "6x"),
72+
("/,,,,.webp", "1e100x"),
73+
]
74+
75+
76+
def test_width_one():
77+
assert srcset_candidates("/a.png 600w") == [("/a.png", "600w")]
78+
79+
80+
def test_width_two():
81+
assert srcset_candidates("a.jpg 123w, b.jpg 1234w") == [
82+
("a.jpg", "123w"),
83+
("b.jpg", "1234w"),
84+
]
85+
86+
87+
@pytest.mark.parametrize("pd", ["1.5w", "9000X", "-23w", "-60x"])
88+
def test_invalid(pd):
89+
assert srcset_candidates("/x.gif " + pd) == []
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!--
2+
Description: entry content srcset relative to document URI
3+
Expect: not bozo and entries[0]['content'][0]['value'] == '<img srcset="http://127.0.0.1:8097/rel/img.png, http://127.0.0.1:8097/rel/img.2x.png 2x" />'
4+
-->
5+
<feed version="0.3" xmlns="http://purl.org/atom/ns#">
6+
<entry>
7+
<content type="text/html" mode="escaped">&lt;img srcset="/rel/img.png, /rel/img.2x.png 2x"&gt;</content>
8+
</entry>
9+
</feed>
10+

0 commit comments

Comments
 (0)