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
6 changes: 5 additions & 1 deletion History.markdown
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
## HEAD
## 0.18.0 / 2026-09-29

### Security

* Escape `xml:lang` values and CDATA terminators in the feed (GHSA-3gx8-pqcm-38hw, #396)

### Development Fixes

Expand Down
12 changes: 7 additions & 5 deletions lib/jekyll-feed/feed.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
{% if page.xsl %}
<?xml-stylesheet type="text/xml" href="{{ '/feed.xslt.xml' | absolute_url }}"?>
{% endif %}
<feed xmlns="http://www.w3.org/2005/Atom" {% if site.lang %}xml:lang="{{ site.lang }}"{% endif %}>
<feed xmlns="http://www.w3.org/2005/Atom" {% if site.lang %}xml:lang="{{ site.lang | xml_escape }}"{% endif %}>
<generator uri="https://jekyllrb.com/" version="{{ jekyll.version }}">Jekyll</generator>
<link href="{{ page.url | absolute_url }}" rel="self" type="application/atom+xml" />
<link href="{{ '/' | absolute_url }}" rel="alternate" type="text/html" {% if site.lang %}hreflang="{{ site.lang }}" {% endif %}/>
<link href="{{ '/' | absolute_url }}" rel="alternate" type="text/html" {% if site.lang %}hreflang="{{ site.lang | xml_escape }}" {% endif %}/>
<updated>{{ site.time | date_to_xmlschema }}</updated>
<id>{{ page.url | absolute_url | xml_escape }}</id>

Expand Down Expand Up @@ -68,8 +68,10 @@
{% endunless %}
{% assign posts = posts | sort: "date" | reverse %}
{% assign posts_limit = site.feed.posts_limit | default: 10 %}
{% assign cdata_end_token = "]]>" %}
{% assign cdata_end_token_escaped = "]]]]><![CDATA[>" %}
{% for post in posts limit: posts_limit %}
<entry{% if post.lang %}{{" "}}xml:lang="{{ post.lang }}"{% endif %}>
<entry{% if post.lang %}{{" "}}xml:lang="{{ post.lang | xml_escape }}"{% endif %}>
{% assign post_title = post.title | smartify | strip_html | normalize_whitespace | xml_escape %}

<title type="html">{{ post_title }}</title>
Expand All @@ -79,7 +81,7 @@
<id>{{ post.id | absolute_url | xml_escape }}</id>
{% assign excerpt_only = post.feed.excerpt_only | default: site.feed.excerpt_only %}
{% unless excerpt_only %}
<content type="html" xml:base="{{ post.url | absolute_url | xml_escape }}"><![CDATA[{{ post.content | strip }}]]></content>
<content type="html" xml:base="{{ post.url | absolute_url | xml_escape }}"><![CDATA[{{ post.content | strip | replace: cdata_end_token, cdata_end_token_escaped }}]]></content>
{% endunless %}

{% assign post_author = post.author | default: post.authors[0] | default: site.author %}
Expand Down Expand Up @@ -112,7 +114,7 @@

{% assign post_summary = post.description | default: post.excerpt %}
{% if post_summary and post_summary != empty %}
<summary type="html"><![CDATA[{{ post_summary | strip_html | normalize_whitespace }}]]></summary>
<summary type="html"><![CDATA[{{ post_summary | strip_html | normalize_whitespace | replace: cdata_end_token, cdata_end_token_escaped }}]]></summary>
{% endif %}

{% assign post_image = post.image.path | default: post.image %}
Expand Down
2 changes: 1 addition & 1 deletion lib/jekyll-feed/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Jekyll
module Feed
VERSION = "0.17.0"
VERSION = "0.18.0"
end
end
109 changes: 109 additions & 0 deletions spec/escaping_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

require "spec_helper"
require "tmpdir"

describe "JekyllFeed output escaping" do
let(:tmp_dir) { Dir.mktmpdir("jekyll-feed") }
let(:tmp_source) { File.join(tmp_dir, "source") }
let(:tmp_dest) { File.join(tmp_dir, "dest") }
let(:overrides) { {} }
let(:posts) { {} }
let(:site) do
Jekyll::Site.new(Jekyll.configuration({
"source" => tmp_source,
"destination" => tmp_dest,
"url" => "http://example.org",
"plugins" => ["jekyll-feed"],
}.merge(overrides)))
end
let(:contents) { File.read(File.join(tmp_dest, "feed.xml")) }
let(:feed) do
Nokogiri::XML(contents, &:strict).tap(&:remove_namespaces!)
end
let(:entries) { feed.xpath("/feed/entry") }

before(:each) do
FileUtils.mkdir_p(File.join(tmp_source, "_posts"))
posts.each do |name, (front_matter, body)|
File.write(
File.join(tmp_source, "_posts", name),
"#{front_matter.merge("title" => "Post").to_yaml}---\n#{body}\n"
)
end
site.process
end

after(:each) do
FileUtils.rm_rf(tmp_dir)
end

context "with a post.lang containing markup" do
let(:lang) { 'en" xml:base="https://evil.example/" xmlns:x="urn:x" x:injected="1' }
let(:posts) { { "2026-01-01-lang.md" => [{ "lang" => lang }, "Hello"] } }

it "escapes the language attribute" do
expect(contents).to include(
'<entry xml:lang="en&quot; xml:base=&quot;https://evil.example/&quot; ' \
'xmlns:x=&quot;urn:x&quot; x:injected=&quot;1">'
)
end

it "does not inject additional attributes" do
entry = entries.first
expect(entry.attributes.keys).to eql(["lang"])
expect(entry["lang"]).to eql(lang)
end
end

context "with a post.lang that closes the element" do
let(:posts) do
{ "2026-01-01-lang.md" => [{ "lang" => 'en"><title>Forged</title><x a="' }, "Hello"] }
end

it "produces a well-formed feed with no injected elements" do
expect(entries.length).to eql(1)
expect(entries.first.xpath("title").map(&:text)).to eql(["Post"])
end
end

context "with a site.lang containing markup" do
let(:lang) { 'en" x="1' }
let(:overrides) { { "lang" => lang } }
let(:posts) { { "2026-01-01-post.md" => [{}, "Hello"] } }

it "escapes the feed language attributes" do
expect(contents).to include('<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en&quot; x=&quot;1">')
expect(contents).to include('hreflang="en&quot; x=&quot;1"')
expect(feed.root["lang"]).to eql(lang)
expect(feed.at_xpath("/feed/link[@rel='alternate']")["hreflang"]).to eql(lang)
end
end

context "with post content containing a CDATA end token" do
let(:body) do
"<p>hi</p>]]></content></entry><entry><title>Forged entry</title>" \
"<id>urn:forged</id><content type=\"html\"><![CDATA[x"
end
let(:posts) { { "2026-01-01-raw.html" => [{}, body] } }

it "produces a well-formed feed with no injected entries" do
expect(entries.length).to eql(1)
expect(feed.xpath("//id").map(&:text)).to_not include("urn:forged")
end

it "preserves the content" do
expect(entries.first.at_xpath("content").text).to eql(body)
end
end

context "with a post description containing a CDATA end token" do
let(:description) { "Summary ]]> with an end token" }
let(:posts) { { "2026-01-01-summary.md" => [{ "description" => description }, "Hello"] } }

it "produces a well-formed feed that preserves the summary" do
expect(entries.length).to eql(1)
expect(entries.first.at_xpath("summary").text).to eql(description)
end
end
end
Loading