diff --git a/custom_components/feedparser/parser.py b/custom_components/feedparser/parser.py index 57a1a475..44be38bb 100644 --- a/custom_components/feedparser/parser.py +++ b/custom_components/feedparser/parser.py @@ -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) @@ -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 diff --git a/custom_components/feedparser/sanitize.py b/custom_components/feedparser/sanitize.py index 24783af4..4620c8bc 100644 --- a/custom_components/feedparser/sanitize.py +++ b/custom_components/feedparser/sanitize.py @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/tests/test_parser.py b/tests/test_parser.py index 53b81860..48e0753e 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -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 = """ + + Relative summary + + Inner links + abs + rel full + bad

]]>
+
+
+""" + + +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