discourse/plugins/discourse-graphviz/plugin.rb
Penar Musaraj 472f9e1f78 SECURITY: Sanitize graphviz SVG anchor links to prevent XSS
Graphviz can generate SVG output with anchor elements containing
arbitrary URLs specified by users. This change implements server-side
sanitization to allow only http and https URLs.
2026-03-19 15:21:28 +00:00

115 lines
3.7 KiB
Ruby

# frozen_string_literal: true
# name: discourse-graphviz
# about: Provides the ability to add graphs to posts using the DOT language.
# meta_topic_id: 97554
# version: 0.0.1
# authors: Maja Komel, Joffrey Jaffeux
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-graphviz
enabled_site_setting :discourse_graphviz_enabled
register_svg_icon "diagram-project"
register_asset "stylesheets/common/graphviz.scss"
module ::DiscourseGraphviz
ALLOWED_URL_SCHEMES = %w[http https].freeze
def self.context
context = MiniRacer::Context.new
context.load("#{Rails.root}/plugins/discourse-graphviz/public/javascripts/viz-3.0.1.js")
context
end
def self.allowed_svg_xpath
@@allowed_svg_xpath ||=
"//*[#{UploadCreator::ALLOWED_SVG_ELEMENTS.map { |e| "name()!='#{e}'" }.join(" and ")}]"
end
def self.sanitize_svg_links(svg_node)
svg_node
.css("a")
.each do |anchor|
href = anchor["href"] || anchor["xlink:href"]
next unless href
begin
uri = URI.parse(href)
scheme = uri.scheme&.downcase
if scheme && !ALLOWED_URL_SCHEMES.include?(scheme)
anchor.replace(anchor.children)
elsif scheme.nil? && href.strip.match?(/\A\s*javascript:/i)
anchor.replace(anchor.children)
end
rescue URI::InvalidURIError
anchor.replace(anchor.children)
end
end
end
end
after_initialize do
on(:before_post_process_cooked) do |doc, post|
if SiteSetting.discourse_graphviz_enabled
doc
.css("div.graphviz")
.each do |graph|
engine = graph.attribute("data-engine").value
svg_graph =
begin
DiscourseGraphviz.context.eval(
"vizRenderStringSync(#{graph.children[0].content.inspect}, {engine: '#{engine}'})",
)
rescue StandardError
nil
end
next if svg_graph.nil?
should_use_svg = SiteSetting.graphviz_default_svg
should_use_svg ||= graph.classes.include?("graphviz-svg")
should_use_svg &&= !graph.classes.include?("graphviz-no-svg")
if should_use_svg
# Changing to Nokogiri::HTML5.fragment returns `nil` for `.css('svg')`
# rubocop:todo Discourse/NoNokogiriHtmlFragment
new_graph_node = Nokogiri::HTML.fragment(svg_graph).css("svg").first
# rubocop:enable Discourse/NoNokogiriHtmlFragment
new_graph_node["class"] = "graphviz-svg-render"
DiscourseGraphviz.sanitize_svg_links(new_graph_node)
new_graph_node.xpath(DiscourseGraphviz.allowed_svg_xpath).remove
graph.replace new_graph_node
next
end
tmp_svg = Tempfile.new(%w[svgfile .svg])
tmp_png = Tempfile.new(%w[vizgraph- .png])
tmp_svg.write(svg_graph)
tmp_svg.rewind
graph_title =
Nokogiri
.parse(svg_graph)
.at("//comment()[contains(.,'Title')]")
&.content
&.match(/Title:\s(?<title>.+)\sPages:/)
&.[](:title)
filename = graph_title != "%0" ? graph_title : File.basename(tmp_png.path)
Discourse::Utils.execute_command("convert", "-density", "300", tmp_svg.path, tmp_png.path)
upload = UploadCreator.new(tmp_png, filename).create_for(-1)
# replace div.graphviz with image node
new_graph_node = Nokogiri::XML::Node.new("img", doc)
new_graph_node["src"] = upload.url
new_graph_node["alt"] = filename
graph.replace new_graph_node
tmp_svg.close!
tmp_png.close!
end
end
end
end