From a47ac7d5ef074c15fa31a437764a8abd981cf5ac Mon Sep 17 00:00:00 2001 From: Robert Fletcher Date: Thu, 22 Oct 2026 09:55:45 -0700 Subject: [PATCH] Escape feed names in story list **What** - Render the feed name in the story template with the escaped `{{- }}` form instead of raw `{{= }}`. - Run feed names from OPML imports through `ContentSanitizer`, as `Feed::Create` already does. **Why** Feed names from OPML imports were saved as-is and then inserted into the story list as raw HTML, so markup in a feed name could run in the reader's browser. **Notes** Names already cleaned by the sanitizer have `&` stored as `&`, which now shows as `&` in the story list, matching the feeds page. --- app/commands/feed/import_from_opml.rb | 4 ++-- app/views/stories/_templates.html.erb | 2 +- spec/commands/feed/import_from_opml_spec.rb | 15 +++++++++++++++ spec/javascript/setup.ts | 2 +- spec/javascript/spec/views/story_view_spec.ts | 10 ++++++++++ 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/commands/feed/import_from_opml.rb b/app/commands/feed/import_from_opml.rb index 16d23faf3..71457f0d2 100644 --- a/app/commands/feed/import_from_opml.rb +++ b/app/commands/feed/import_from_opml.rb @@ -28,8 +28,8 @@ def find_or_create_group(group_name, user) end def create_feed(parsed_feed, group, user) - feed = user.feeds.where(**parsed_feed.slice(:name, :url)) - .first_or_initialize + name = parsed_feed[:name] && ContentSanitizer.call(parsed_feed[:name]) + feed = user.feeds.where(name:, url: parsed_feed[:url]).first_or_initialize find_feed_name(feed, parsed_feed) feed.last_fetched = 1.day.ago if feed.new_record? feed.group_id = group.id if group diff --git a/app/views/stories/_templates.html.erb b/app/views/stories/_templates.html.erb index 5edd72714..741ce4f7e 100644 --- a/app/views/stories/_templates.html.erb +++ b/app/views/stories/_templates.html.erb @@ -5,7 +5,7 @@

- {{= source }} + {{- source }}

diff --git a/spec/commands/feed/import_from_opml_spec.rb b/spec/commands/feed/import_from_opml_spec.rb index d5ab46139..84fe8b0f1 100644 --- a/spec/commands/feed/import_from_opml_spec.rb +++ b/spec/commands/feed/import_from_opml_spec.rb @@ -61,6 +61,21 @@ def find_feed(feed_details) end end + def evil_opml + <<~XML + + + + XML + end + + it "sanitizes feed names" do + described_class.call(evil_opml, user: default_user) + + expect(Feed.last.name).to eq(' Evil') + end + context "adding group_id for existing feeds" do it "retains exising feeds" do feed1 = create_feed(tmw_football) diff --git a/spec/javascript/setup.ts b/spec/javascript/setup.ts index 58ed016d7..9ee9a9e23 100644 --- a/spec/javascript/setup.ts +++ b/spec/javascript/setup.ts @@ -36,7 +36,7 @@ const templateHTML = [ ' ', "
", '

', - " {{= source }}", + " {{- source }}", "

", " ", '
', diff --git a/spec/javascript/spec/views/story_view_spec.ts b/spec/javascript/spec/views/story_view_spec.ts index 0d5c4ec9e..42fb8ecb1 100644 --- a/spec/javascript/spec/views/story_view_spec.ts +++ b/spec/javascript/spec/views/story_view_spec.ts @@ -49,6 +49,16 @@ describe("StoryView", function () { assertPropertyRendered(view.el, story, "source"); }); + it("should escape blog title", function () { + const evilStory = new Story({ ...story.attributes, source: "" }); + const evilView = new StoryView({ model: evilStory }); + evilView.render(); + + const blogTitle = evilView.el.querySelector(".blog-title"); + expect(blogTitle.querySelector("img")).toBeNull(); + expect(blogTitle.textContent.trim()).toBe(""); + }); + it("should render story headline", function () { assertTagExists(view.el, ".story-title"); assertPropertyRendered(view.el, story, "headline");