Publify - Stored Cross-Site Scripting (XSS) in Editor
Description
In Publify, versions v8.0 to v9.2.4 are vulnerable to stored XSS. A user with a “publisher” role is able to inject and execute arbitrary JavaScript code while creating a page/article.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Publify v8.0 to v9.2.4 is vulnerable to stored XSS, allowing a publisher role user to inject arbitrary JavaScript when creating pages/articles.
Vulnerability
Publify versions v8.0 through v9.2.4 are vulnerable to stored cross-site scripting (XSS) [1]. The vulnerability resides in the content sanitization process; the html_postprocess method in ContentBase did not properly sanitize HTML, allowing malicious JavaScript to be stored in page/article content [2]. The fix ensures that content is sanitized using ActionView::Helpers::SanitizeHelper [2].
Exploitation
An attacker with a "publisher" role can inject arbitrary JavaScript while creating or editing a page/article [1]. The injected script is stored in the database and executed when any user views the affected content. No additional authentication or user interaction beyond viewing the page is required for the script to execute.
Impact
Successful exploitation leads to stored XSS, enabling the attacker to execute arbitrary JavaScript in the context of the victim's browser. This can result in session hijacking, defacement, or theft of sensitive information. The attacker gains the ability to perform actions on behalf of the victim within the Publify application.
Mitigation
The vulnerability is fixed in commit fefd5f7 [2]. Users should upgrade to a patched version (likely after v9.2.4). As of the publication date (2021-11-10), no official release containing the fix is mentioned; however, applying the commit or upgrading to the latest version from the repository is recommended [3]. No workaround is provided in the references.
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
publify_coreRubyGems | >= 8.0, < 9.2.5 | 9.2.5 |
Affected products
3- osv-coords2 versions
>= 8.0.0, < 9.2.4+ 1 more
- (no CPE)range: >= 8.0.0, < 9.2.4
- (no CPE)range: >= 8.0, < 9.2.5
- publify_core/publify_corev5Range: v8.0
Patches
1fefd5f76302aEnsure all Content html is sanitized
17 files changed · +102 −41
publify_core/app/helpers/base_helper.rb+6 −1 modified@@ -240,10 +240,15 @@ def fetch_html_content_for_feeds(item, this_blog) end def nofollowify_links(string) + raise ArgumentError, "string", "must be html_safe" unless string.html_safe? + if this_blog.dofollowify string else - string.gsub(/<a(.*?)>/i, '<a\1 rel="nofollow">') + followify_scrubber = Loofah::Scrubber.new do |node| + node.set_attribute "rel", "nofollow" if node.name == "a" + end + sanitize h(string), scrubber: followify_scrubber end end
publify_core/app/models/content_base.rb+9 −3 modified@@ -5,6 +5,12 @@ def self.included(base) base.extend ClassMethods end + class ContentTextHelpers + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::TextHelper + include ActionView::Helpers::SanitizeHelper + end + attr_accessor :just_changed_published_status alias just_changed_published_status? just_changed_published_status @@ -39,10 +45,10 @@ def generate_html(field, text = nil) html_postprocess(field, html).to_s end - # Post-process the HTML. This is a noop by default, but Comment overrides it - # to enforce HTML sanity. + # Post-process the HTML def html_postprocess(_field, html) - html + helper = ContentTextHelpers.new + helper.sanitize html end def html_preprocess(_field, html)
publify_core/app/models/feedback.rb+0 −6 modified@@ -11,12 +11,6 @@ class Feedback < ApplicationRecord include PublifyGuid include ContentBase - class ContentTextHelpers - include ActionView::Helpers::UrlHelper - include ActionView::Helpers::TextHelper - include ActionView::Helpers::SanitizeHelper - end - validate :feedback_not_closed, on: :create validates :article, presence: true
publify_core/app/views/articles/_article_excerpt.html.erb+1 −1 modified@@ -5,7 +5,7 @@ <p><%= link_to_permalink article, t('.continue_reading') %></p> </div> <% else %> - <%= raw article.html(:body) %> + <%= article.html(:body) %> <% if article.extended? %> <div class="extended"> <p><%= link_to_permalink article, t('.continue_reading') %></p>
publify_core/app/views/articles/_full_article_content.html.erb+2 −2 modified@@ -1,4 +1,4 @@ <% cache article do %> - <%= raw article.html(:body) %> - <%= raw article.html(:extended) %> + <%= article.html(:body) %> + <%= article.html(:extended) %> <% end %>
publify_core/app/views/articles/view_page.html.erb+1 −1 modified@@ -1,3 +1,3 @@ <div id="viewpage"> - <%= raw html @page %> + <%= html @page %> </div>
publify_core/app/views/comments/_comment.html.erb+1 −1 modified@@ -6,7 +6,7 @@ <%= t('.said') %> <%= display_date_and_time comment.created_at %>: </p> <div class="content"> - <%= raw nofollowify_links comment.generate_html(:body) %> + <%= nofollowify_links comment.generate_html(:body) %> <% unless comment.published? %> <div class="spamwarning"> <%= t('.this_comment_has_been_flagged_for_moderator_approval') %>
publify_core/app/views/notes/index.html.erb+1 −1 modified@@ -2,7 +2,7 @@ <% for note in @notes %> <div class='h-entry hentry h-as-note'> <article> - <p class='p-name entry-title e-content entry-content article'><%= raw note.html(:body) %></p> + <p class='p-name entry-title e-content entry-content article'><%= note.html(:body) %></p> <footer> <small><%= link_to_permalink(note, display_date_and_time(note.published_at)) %></small> </footer>
publify_core/app/views/notes/_note.html.erb+1 −1 modified@@ -1,7 +1,7 @@ <% cache [note, note.user] do %> <article class='status'> <%= author_picture note %> - <div class='p-name entry-title e-content entry-content article'><%= raw note.html(:body) %></div> + <div class='p-name entry-title e-content entry-content article'><%= note.html(:body) %></div> <footer> <small> <%= link_to_permalink(note, display_date_and_time(note.published_at)) %> |
publify_core/spec/helpers/base_helper_spec.rb+19 −5 modified@@ -160,6 +160,8 @@ def parse_request(_contents, _request_params) end describe "#nofollowify_links" do + let(:original_html) { '<a href="http://myblog.net">my blog</a>'.html_safe } + before do @blog = create :blog end @@ -168,16 +170,28 @@ def parse_request(_contents, _request_params) @blog.dofollowify = false @blog.save - expect(nofollowify_links('<a href="http://myblog.net">my blog</a>')). - to eq('<a href="http://myblog.net" rel="nofollow">my blog</a>') + result = nofollowify_links(original_html) + + aggregate_failures do + expect(result).to eq('<a href="http://myblog.net" rel="nofollow">my blog</a>') + expect(result).to be_html_safe + end end - it "with dofollowify enabled, links should be nofollowed" do + it "with dofollowify enabled, links should be not nofollowed" do @blog.dofollowify = true @blog.save - expect(nofollowify_links('<a href="http://myblog.net">my blog</a>')). - to eq('<a href="http://myblog.net">my blog</a>') + result = nofollowify_links(original_html) + + aggregate_failures do + expect(result).to eq('<a href="http://myblog.net">my blog</a>') + expect(result).to be_html_safe + end + end + + it "does not accept unsafe html" do + expect { nofollowify_links("just an unsafe string") }.to raise_error ArgumentError end end
publify_core/spec/models/article_spec.rb+8 −0 modified@@ -398,6 +398,14 @@ end end + describe "#html" do + let(:article) { build_stubbed :article } + + it "returns an html_safe string" do + expect(article.html).to be_html_safe + end + end + describe "#comment_url" do it "renders complete url of comment" do article = build_stubbed(:article, id: 123)
publify_core/spec/models/comment_spec.rb+37 −2 modified@@ -257,10 +257,45 @@ def valid_comment(options = {}) end end - describe "#generate_html" do + describe "#html" do it "renders email addresses in the body" do comment = build_stubbed(:comment, body: "foo@example.com") - expect(comment.generate_html(:body)).to match(/mailto:/) + expect(comment.html).to match(/mailto:/) + end + + it "returns an html_safe string" do + comment = build_stubbed(:comment, body: "Just a comment") + expect(comment.html).to be_html_safe + end + + context "with an evil comment" do + let(:comment) { build_stubbed :comment, body: "Test foo <script>do_evil();</script>" } + let(:blog) { comment.article.blog } + + ["", "textile", "markdown", "smartypants", "markdown smartypants"].each do |filter| + it "rejects xss attempt with filter '#{filter}'" do + blog.comment_text_filter = filter + + ActiveSupport::Deprecation.silence do + assert comment.html(:body) !~ /<script>/ + end + end + end + end + + context "with a markdown comment with italic and bold" do + let(:comment) { build(:comment, body: "Comment body _italic_ **bold**") } + let(:blog) { comment.article.blog } + + it "converts the comment markup to html" do + blog.comment_text_filter = "markdown" + result = comment.html + + aggregate_failures do + expect(result).to match(%r{<em>italic</em>}) + expect(result).to match(%r{<strong>bold</strong>}) + end + end end end end
publify_core/spec/models/content_spec.rb+0 −15 modified@@ -144,19 +144,4 @@ it { expect(content.author_name).to eq(author.login) } end end - - describe "#generate_html" do - context "with a blog with markdown filter" do - let!(:blog) { create(:blog, comment_text_filter: "markdown") } - - context "comment with italic and bold" do - let(:comment) { build(:comment, body: "Comment body _italic_ **bold**") } - - it "converts the comment markup to HTML" do - expect(comment.generate_html(:body)).to match(%r{<em>italic</em>}) - expect(comment.generate_html(:body)).to match(%r{<strong>bold</strong>}) - end - end - end - end end
publify_core/spec/models/note_spec.rb+7 −0 modified@@ -255,6 +255,13 @@ it { expect(note.twitter_message.length).to eq(140) } end end + + describe "#html" do + it "returns an html_safe string" do + note = build(:note, body: "A test tweet with a #hashtag") + expect(note.html).to be_html_safe + end + end end context "with a dofollowify blog" do
publify_core/spec/models/page_spec.rb+7 −0 modified@@ -89,4 +89,11 @@ it { expect(page.redirect).to be_blank } end end + + describe "#html" do + it "returns an html_safe string" do + page = build(:page) + expect(page.html).to be_html_safe + end + end end
themes/bootstrap-2/views/articles/view_page.html.erb+1 −1 modified@@ -1,2 +1,2 @@ <h1 class='page-header'><%= link_to_permalink(@page, @page.title) %></h1> -<%= raw @page.html %> +<%= @page.html %>
themes/bootstrap-2/views/comments/_comment.html.erb+1 −1 modified@@ -5,7 +5,7 @@ <%= t('.by') %> <%= comment.url.blank? ? h(comment.author) : nofollowified_link_to(h(comment.author), comment.url) %> <%= display_date_and_time comment.created_at %> </h4> - <%= raw comment.html %> + <%= comment.html %> <%- unless comment.published? %> <div class="spamwarning"><%= t('.this_comment_has_been_flagged_for_moderator_approval') %></div> <%- end %>
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-wmh9-x28j-c6grghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-25974ghsaADVISORY
- github.com/publify/publify/commit/fefd5f76302adcc425b2b6e7e7d23587cfc0083eghsax_refsource_MISCWEB
- www.whitesourcesoftware.com/vulnerability-database/CVE-2021-25974ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.