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
21 changes: 21 additions & 0 deletions config/initializers/sax_machine.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

# sax-machine hardcodes `ctx.replace_entities = true` in its Nokogiri handler,
# so a hostile feed can declare an external entity and have us resolve it while
# parsing (XXE): `file://` reads local files and `http://` reaches private
# addresses, both outside the SafeFetch guard that protects the fetch itself.
#
# Turning substitution off only affects entities that point at a SYSTEM
# resource. Predefined (`&`), numeric (`é`) and internally declared
# entities still resolve, so feeds that declare their own HTML entities in a
# DOCTYPE keep working.
module SAXMachine::DisableExternalEntities
def sax_parse(xml_input)
parser = Nokogiri::XML::SAX::Parser.new(self)
parser.parse(xml_input) { |ctx| ctx.replace_entities = false }
end
end

if SAXMachine.handler == :nokogiri
SAXMachine::SAXNokogiriHandler.prepend(SAXMachine::DisableExternalEntities)
end
44 changes: 44 additions & 0 deletions spec/utils/feedjira_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true

# Guards config/initializers/sax_machine.rb. These assert parser behaviour
# rather than the patch itself, so they still fail if a sax-machine upgrade
# stops the prepend from applying.
RSpec.describe Feedjira do
def secret_file
Tempfile.new("xxe").tap do |file|
file.write("TOP-SECRET-CONTENTS")
file.close
end
end

def parse_feed(doctype, title)
described_class.parse(<<~XML)
<?xml version="1.0"?>
#{doctype}
<rss version="2.0"><channel>
<title>#{title}</title>
<link>http://example.com</link>
</channel></rss>
XML
end

it "does not resolve external entities pointing at local files" do
secret = secret_file
doctype = %(<!DOCTYPE rss [<!ENTITY xxe SYSTEM "file://#{secret.path}">]>)

expect(parse_feed(doctype, "&xxe;").title.to_s)
.not_to include("TOP-SECRET-CONTENTS")
end

it "still resolves predefined and numeric entities" do
expect(parse_feed("", "Tom &amp; Jerry caf&#233;").title)
.to eq("Tom & Jerry café")
end

it "still resolves entities declared inside the document" do
doctype = %(<!DOCTYPE rss [<!ENTITY nbsp "&#160;">]>)

expect(parse_feed(doctype, "Feed&nbsp;Title").title)
.to eq("Feed\u00A0Title")
end
end
Loading