Merge pull request 'Improve RSS compliance' (#7932) from fix-rss into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7932
This commit is contained in:
lain 2026-07-24 17:33:40 +00:00
commit c2b8c67808
13 changed files with 546 additions and 99 deletions

View file

@ -0,0 +1,59 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Feed.FeedViewTest do
use Pleroma.DataCase, async: true
alias Pleroma.Web.Feed.FeedView
test "escapes fallback content" do
data = %{
"attachment" => [],
"content" => "",
"summary" => "<script>alert('feed')</script>"
}
expected = "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;"
assert FeedView.atom_content_encoded(data) == expected
assert FeedView.rss_content_encoded(data) == expected
end
test "does not preview or classify sensitive attachments" do
attachment = %{
"name" => "spoiler",
"url" => [%{"href" => "https://example.com/spoiler.png", "mediaType" => "image/png"}]
}
data = %{
"attachment" => [attachment],
"content" => "content warning",
"sensitive" => true
}
assert FeedView.atom_content_encoded(data) == "content warning"
assert FeedView.rss_content_encoded(data) == "content warning"
assert FeedView.media_content_xml(attachment, true) == ""
assert FeedView.media_content_xml(attachment, "true") == ""
media_content = FeedView.media_content_xml(attachment, false)
assert media_content =~ "<media:description type=\"plain\">spoiler</media:description>"
refute media_content =~ "media:rating"
assert FeedView.media_content_xml(Map.delete(attachment, "name"), false) ==
~s(<media:content url="https://example.com/spoiler.png" type="image/png" medium="image"/>\n)
end
test "does not treat a legacy false string as sensitive" do
data = %{
"attachment" => [
%{"url" => [%{"href" => "https://example.com/image.png", "mediaType" => "image/png"}]}
],
"content" => "visible",
"sensitive" => "false"
}
assert FeedView.atom_content_encoded(data) =~ "<img"
end
end

View file

@ -30,9 +30,18 @@ defmodule Pleroma.Web.Feed.TagControllerTest do
%{
"url" => [
%{
"href" =>
"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
"href" => "https://peertube.moe/video.mp4?first=one&second=two",
"mediaType" => "video/mp4",
"size" => "123",
"type" => "Link"
}
]
},
%{
"url" => [
%{
"href" => "https://peertube.moe/notes.pdf",
"mediaType" => "application/pdf",
"type" => "Link"
}
]
@ -63,6 +72,15 @@ defmodule Pleroma.Web.Feed.TagControllerTest do
]
assert xpath(xml, ~x"//feed/entry/author/name/text()"ls) == [user.nickname, user.nickname]
assert xpath(xml, ~x"//feed/entry/link[@rel='self']/@href"sl) == []
assert length(xpath(xml, ~x"//feed/entry/link[@rel='alternate']/@href"sl)) == 2
assert xpath(xml, ~x"//feed/entry/link[@rel='enclosure']/@href"sl) == [
"https://peertube.moe/video.mp4?first=one&second=two",
"https://peertube.moe/notes.pdf"
]
assert xpath(xml, ~x"//feed/entry/link[@rel='enclosure']/@length"sl) == ["123"]
conn =
conn
@ -96,17 +114,28 @@ defmodule Pleroma.Web.Feed.TagControllerTest do
%{
"url" => [
%{
"href" =>
"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4",
"href" => "https://peertube.moe/video.mp4?first=one&second=two",
"mediaType" => "video/mp4",
"size" => "123",
"type" => "Link"
}
]
},
%{
"url" => [
%{
"href" => "https://peertube.moe/notes.pdf",
"mediaType" => "application/pdf",
"type" => "Link"
}
]
}
])
edited_at = NaiveDateTime.add(object.updated_at, 60)
object
|> Ecto.Changeset.change(data: object_data)
|> Ecto.Changeset.change(data: object_data, updated_at: edited_at)
|> Pleroma.Repo.update()
{:ok, activity2} = CommonAPI.post(user, %{status: "42 This is :moominmamma #PleromaArt"})
@ -136,15 +165,27 @@ defmodule Pleroma.Web.Feed.TagControllerTest do
~c"yeah #PleromaArt"
]
assert xpath(xml, ~x"//channel/item/guid/@isPermaLink"sl) == ["true", "true"]
assert xpath(xml, ~x"//channel/item/pubDate/text()"sl) == [
FeedView.to_rfc2822(activity2.data["published"]),
FeedView.to_rfc2822(activity1.data["published"])
]
assert xpath(xml, ~x"//channel/item/enclosure/@url"sl) == [
"https://peertube.moe/static/webseed/df5f464b-be8d-46fb-ad81-2d4c2d1630e3-480.mp4"
assert xpath(xml, ~x"//channel/lastBuildDate/text()"s) ==
FeedView.to_rfc2822(edited_at)
assert xpath(xml, ~x"//channel/item/media:content/@url"sl) == [
"https://peertube.moe/video.mp4?first=one&second=two",
"https://peertube.moe/notes.pdf"
]
assert xpath(xml, ~x"//channel/item/enclosure/@url"sl) == [
"https://peertube.moe/video.mp4?first=one&second=two"
]
assert xpath(xml, ~x"//channel/item/enclosure/@length"sl) == ["123"]
obj1 = Object.normalize(activity1, fetch: false)
obj2 = Object.normalize(activity2, fetch: false)
@ -181,6 +222,90 @@ defmodule Pleroma.Web.Feed.TagControllerTest do
]
end
test "does not fabricate lastBuildDate for an empty RSS feed", %{conn: conn} do
response =
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "empty.rss"))
|> response(200)
refute response =~ "<lastBuildDate>"
end
test "provides a valid updated timestamp for an empty Atom feed", %{conn: conn} do
response =
conn
|> put_req_header("accept", "application/atom+xml")
|> get(tag_feed_path(conn, :feed, "empty.atom"))
|> response(200)
updated = response |> parse() |> xpath(~x"//feed/updated/text()"s)
assert {:ok, _datetime, 0} = DateTime.from_iso8601(updated)
end
test "escapes Atom IDs and tag feed self links", %{conn: conn} do
user = insert(:user, local: false)
object_id = "https://remote.example/object?first=one&second=two"
note =
insert(:note,
user: user,
object_local: false,
data: %{
"content" => "remote #atomlink",
"external_url" => object_id,
"id" => object_id,
"summary" => "",
"tag" => ["atomlink"]
}
)
insert(:note_activity, user: user, note: note, local: false, object_local: false)
response =
conn
|> put_req_header("accept", "application/atom+xml")
|> get("/tags/atomlink.atom?third=three&fourth=four")
|> response(200)
assert response =~ "https://remote.example/object?first=one&amp;second=two"
assert response =~ "third=three&amp;fourth=four"
xml = parse(response)
assert xpath(xml, ~x"//feed/entry/id/text()"s) == object_id
end
test "escapes and round-trips RSS item permalinks", %{conn: conn} do
user = insert(:user, local: false)
permalink = "https://remote.example/post?first=one&second=two"
note =
insert(:note,
user: user,
object_local: false,
data: %{
"content" => "remote #rsslink",
"external_url" => permalink,
"summary" => "",
"tag" => ["rsslink"]
}
)
insert(:note_activity, user: user, note: note, local: false, object_local: false)
response =
conn
|> put_req_header("accept", "application/rss+xml")
|> get(tag_feed_path(conn, :feed, "rsslink.rss"))
|> response(200)
assert response =~ "https://remote.example/post?first=one&amp;second=two"
xml = parse(response)
assert xpath(xml, ~x"//channel/item/guid/text()"s) == permalink
assert xpath(xml, ~x"//channel/item/link/text()"s) == permalink
end
describe "private instance" do
setup do: clear_config([:instance, :public], false)

View file

@ -83,12 +83,12 @@ defmodule Pleroma.Web.Feed.UserControllerTest do
|> get(user_feed_path(conn, :feed, user.nickname))
|> response(200)
activity_titles =
resp
|> SweetXml.parse()
|> SweetXml.xpath(~x"//entry/title/text()"l)
xml = SweetXml.parse(resp)
activity_titles = SweetXml.xpath(xml, ~x"//entry/title/text()"l)
assert activity_titles == [~c"Won't, didn'...", ~c"2hu", ~c"2hu & as"]
assert SweetXml.xpath(xml, ~x"//entry/link[@rel='self']/@href"sl) == []
assert length(SweetXml.xpath(xml, ~x"//entry/link[@rel='alternate']/@href"sl)) == 3
assert resp =~ FeedView.escape(object.data["content"])
assert resp =~ FeedView.escape(object.data["summary"])
assert resp =~ FeedView.escape(object.data["context"])
@ -138,6 +138,107 @@ defmodule Pleroma.Web.Feed.UserControllerTest do
assert activity_titles == [~c"2hu & as"]
end
test "escapes query parameters in feed self links", %{conn: conn, user: user} do
for format <- ~w[atom rss] do
response =
conn
|> get("/users/#{user.nickname}/feed.#{format}?first=one&second=two")
|> response(200)
assert response =~ "first=one&amp;second=two"
refute response =~ "first=one&second=two"
end
end
test "keeps fallback summary markup inert in rendered Atom content", %{
conn: conn,
object: object,
user: user
} do
data =
object.data
|> Map.put("content", "")
|> Map.put("summary", "<script>alert('feed')</script>")
object
|> Ecto.Changeset.change(data: data)
|> Pleroma.Repo.update!()
response =
conn
|> get("/users/#{user.nickname}/feed.atom")
|> response(200)
assert response =~
"&amp;lt;script&amp;gt;alert(&amp;#39;feed&amp;#39;)&amp;lt;/script&amp;gt;"
content_values = response |> parse() |> xpath(~x"//entry/content/text()"sl)
assert Enum.any?(content_values, fn content ->
String.starts_with?(content, "&lt;script&gt;alert(&#39;feed&#39;)&lt;/script&gt;")
end)
end
test "does not expose sensitive attachments as enclosures", %{
conn: conn,
object: object,
user: user
} do
object
|> Ecto.Changeset.change(data: Map.put(object.data, "sensitive", true))
|> Pleroma.Repo.update!()
atom_response =
conn
|> get("/users/#{user.nickname}/feed.atom")
|> response(200)
rss_response =
conn
|> get("/users/#{user.nickname}/feed.rss")
|> response(200)
refute atom_response =~ "rel=\"enclosure\""
refute rss_response =~ "<enclosure "
end
test "uses enclosure fallbacks for user feeds", %{conn: conn, object: object, user: user} do
attachment = %{"url" => [%{"href" => "https://example.com/file.bin"}]}
object
|> Ecto.Changeset.change(data: Map.put(object.data, "attachment", [attachment]))
|> Pleroma.Repo.update!()
atom_xml =
conn
|> get("/users/#{user.nickname}/feed.atom")
|> response(200)
|> parse()
assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@href"sl) == [
"https://example.com/file.bin"
]
assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@type"sl) == [
"application/octet-stream"
]
assert xpath(atom_xml, ~x"//entry/link[@rel='enclosure']/@length"sl) == []
rss_xml =
conn
|> get("/users/#{user.nickname}/feed.rss")
|> response(200)
|> parse()
assert xpath(rss_xml, ~x"//item/enclosure/@url"sl) == []
assert xpath(rss_xml, ~x"//item/media:content/@url"sl) == ["https://example.com/file.bin"]
assert xpath(rss_xml, ~x"//item/media:content/@type"sl) == [
"application/octet-stream"
]
end
test "returns 404 for a missing feed", %{conn: conn} do
conn =
conn