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
4 changes: 2 additions & 2 deletions custom_components/feedparser/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ def generate_sensor_entry( # noqa: C901, PLR0912
]
# Before `remove_summary_image` and the truncation, so both work on the
# markup the sensor actually exposes rather than on what the feed sent.
sensor_entry = sanitize_mapping(sensor_entry)
sensor_entry = sanitize_mapping(sensor_entry, config.feed_url)
if config.remove_summary_image and "summary" in sensor_entry:
sensor_entry["summary"] = re.sub(IMAGE_REGEX, "", sensor_entry["summary"])
sensor_entry = _truncate_entry(sensor_entry, config)
Expand Down Expand Up @@ -486,7 +486,7 @@ def generate_channel_info(
image_url = feed_info.logo
if resolved := _resolve_url(image_url, config):
channel_info["image"] = resolved
channel_info = sanitize_mapping(channel_info)
channel_info = sanitize_mapping(channel_info, config.feed_url)
channel_info = _truncate_entry(channel_info, config)
_LOGGER.debug("Feed %s: Generated channel info: %s", config.name, channel_info)
return channel_info
Expand Down
39 changes: 29 additions & 10 deletions custom_components/feedparser/sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
# allow-list is deliberate: this is the same allow-list the library applies to
# HTML typed values, so a feed is sanitised identically in every dialect.
from feedparser.sanitizer import _sanitize_html
from feedparser.urls import make_safe_absolute_uri
from feedparser.urls import make_safe_absolute_uri, resolve_relative_uris

# Values under these keys are URLs, not markup. Running them through the HTML
# sanitiser would escape the `&` of a query string; they are checked against
Expand All @@ -32,11 +32,20 @@
URL_KEYS = frozenset({"link", "image", "audio", "href", "url"})


def sanitize_html(value: str) -> str:
"""Return `value` with everything the allow-list rejects removed."""
def sanitize_html(value: str, base_url: str | None = None) -> str:
"""Return `value` with everything the allow-list rejects removed.

With `base_url`, a relative `href` or `src` in what is left is joined with
it. That runs after the allow-list, so only URLs it let through are
resolved, and a link in a summary points at the feed's host and not at
Home Assistant, like `link` and `links[].href` already do.
"""
if "<" not in value:
return value
return _sanitize_html(value, "utf-8", "text/html")
cleaned = _sanitize_html(value, "utf-8", "text/html")
if base_url:
return resolve_relative_uris(cleaned, base_url, "utf-8", "text/html")
return cleaned


def safe_url(value: str) -> str:
Expand All @@ -49,22 +58,32 @@ def safe_url(value: str) -> str:
return make_safe_absolute_uri(value.strip())


def sanitize_value(key: str, value: Any) -> Any: # noqa: ANN401
def sanitize_value(
key: str,
value: Any, # noqa: ANN401
base_url: str | None = None,
) -> Any: # noqa: ANN401
"""Sanitise one value of an entry, recursing into lists and dicts.

Nested values carry their own key - the `href` of a link, the `url` of a
media item - so the URL rule applies at every depth.
"""
if isinstance(value, str):
return safe_url(value) if key in URL_KEYS else sanitize_html(value)
return safe_url(value) if key in URL_KEYS else sanitize_html(value, base_url)
if isinstance(value, list):
return [sanitize_value(key, item) for item in value]
return [sanitize_value(key, item, base_url) for item in value]
if isinstance(value, dict):
return {nested: sanitize_value(nested, item) for nested, item in value.items()}
return {
nested: sanitize_value(nested, item, base_url)
for nested, item in value.items()
}
return value


def sanitize_mapping(values: dict[str, Any]) -> dict[str, Any]:
def sanitize_mapping(
values: dict[str, Any],
base_url: str | None = None,
) -> dict[str, Any]:
"""Return a copy of an entry or channel with every value sanitised.

A top level URL the allow-list rejects drops out entirely rather than
Expand All @@ -73,7 +92,7 @@ def sanitize_mapping(values: dict[str, Any]) -> dict[str, Any]:
"""
sanitized: dict[str, Any] = {}
for key, value in values.items():
cleaned = sanitize_value(key, value)
cleaned = sanitize_value(key, value, base_url)
if key in URL_KEYS and isinstance(value, str) and value and not cleaned:
continue
sanitized[key] = cleaned
Expand Down
30 changes: 30 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,3 +1020,33 @@ def test_scan_interval_from_minutes(
) -> None:
"""Test conversion of the UI scan interval into a timedelta."""
assert scan_interval_from_minutes(value) == timedelta(minutes=expected_minutes)


FEED_WITH_RELATIVE_SUMMARY_LINKS = """<?xml version="1.0"?>
<rss version="2.0"><channel>
<title>Relative summary</title>
<item>
<title>Inner links</title>
<description><![CDATA[<p><a href="/inner/path">abs</a>
<a href="rel/page">rel</a> <a href="https://other.example/x">full</a>
<img src="/img/a.png"/> <a href="javascript:alert(1)">bad</a></p>]]></description>
</item>
</channel></rss>
"""


def test_relative_links_in_the_summary_html_are_resolved() -> None:
"""Test that `href` and `src` in the summary point at the feed's host."""
config = FeedParserConfig(
feed_url="https://ex.com/feeds/feed.xml",
name="relative",
date_format=DATE_FORMAT,
show_topn=1,
max_text_length=NO_TEXT_LIMIT,
)
summary = parse_feed(FEED_WITH_RELATIVE_SUMMARY_LINKS, config).entries[0]["summary"]
assert 'href="https://ex.com/inner/path"' in summary
assert 'href="https://ex.com/feeds/rel/page"' in summary
assert 'href="https://other.example/x"' in summary
assert 'src="https://ex.com/img/a.png"' in summary
assert "javascript:" not in summary
Loading