docbrown/app/services/markdown_processor/parser.rb
Kim Emmanuel 04fb62a6f5
ignore code blocks when checking markdown for XSS (#20641)
Code blocks are rendered as raw strings and therefore don´t need to be
checked for XSS. Checking them for XSS disallow users to write articles about
XSS in markdown, for example.

Co-authored-by: Mac Siri <mac@forem.com>
2024-03-07 21:03:39 +00:00

152 lines
5.9 KiB
Ruby

module MarkdownProcessor
class Parser
BAD_XSS_REGEX = [
/src=["'](data|&)/i,
%r{data:text/html[,;][\sa-z0-9]*}i,
].freeze
CODE_BLOCKS_REGEX = /(~{3}|`{3}|`{2}|`)[\s\S]*?\1/
WORDS_READ_PER_MINUTE = 275.0
# @param content [String] The user input, mix of markdown and liquid. This might be an
# article's markdown body.
# @param source [Object] The thing associated with the content. This might be an article.
# @param user [User] Who's the one writing the content?
# @param liquid_tag_options [Hash]
#
# @note This is a place to pass in a different policy object. But like, maybe don't do
# that with user input, but perhaps via a data migration script.
#
# @see LiquidTagBase for more information regarding the liquid tag options.
def initialize(content, source: nil, user: nil,
liquid_tag_options: {})
@content = content
@source = source
@user = user
@liquid_tag_options = liquid_tag_options.merge({ source: @source, user: @user })
end
# @param prefix_images_options [Hash] params, that need to be passed further to HtmlParser#prefix_all_images
def finalize(link_attributes: {}, prefix_images_options: { width: 800, synchronous_detail_detection: false })
options = { hard_wrap: true, filter_html: false, link_attributes: link_attributes }
renderer = Redcarpet::Render::HTMLRouge.new(options)
markdown = Redcarpet::Markdown.new(renderer, Constants::Redcarpet::CONFIG)
catch_xss_attempts(@content)
code_tag_content = convert_code_tags_to_triple_backticks(@content)
escaped_content = escape_liquid_tags_in_codeblock(code_tag_content)
html = markdown.render(escaped_content)
sanitized_content = ActionController::Base.helpers.sanitize html, { scrubber: RenderedMarkdownScrubber.new }
begin
# NOTE: [@rhymes] liquid 5.0.0 does not support ActiveSupport::SafeBuffer,
# a String substitute, hence we force the conversion before passing it to Liquid::Template.
# See <https://github.com/Shopify/liquid/issues/1390>
parsed_liquid = Liquid::Template.parse(sanitized_content.to_str, @liquid_tag_options)
html = markdown.render(parsed_liquid.render)
rescue Liquid::SyntaxError => e
html = e.message
end
parse_html(html, prefix_images_options)
end
def calculate_reading_time
word_count = @content.split(/\W+/).count
(word_count / WORDS_READ_PER_MINUTE).ceil
end
def evaluate_markdown(allowed_tags: MarkdownProcessor::AllowedTags::MARKDOWN_PROCESSOR_DEFAULT)
return if @content.blank?
renderer = Redcarpet::Render::HTMLRouge.new(hard_wrap: true, filter_html: false)
markdown = Redcarpet::Markdown.new(renderer, Constants::Redcarpet::CONFIG)
ActionController::Base.helpers.sanitize(markdown.render(@content),
tags: allowed_tags,
attributes: MarkdownProcessor::AllowedAttributes::MARKDOWN_PROCESSOR)
end
def evaluate_limited_markdown(allowed_tags: MarkdownProcessor::AllowedTags::MARKDOWN_PROCESSOR_LIMITED)
evaluate_markdown(allowed_tags: allowed_tags)
end
# rubocop:disable Layout/LineLength
def evaluate_inline_limited_markdown(allowed_tags: MarkdownProcessor::AllowedTags::MARKDOWN_PROCESSOR_INLINE_LIMITED)
evaluate_markdown(allowed_tags: allowed_tags)
end
# rubocop:enable Layout/LineLength
def evaluate_listings_markdown(allowed_tags: MarkdownProcessor::AllowedTags::MARKDOWN_PROCESSOR_LISTINGS)
evaluate_markdown(allowed_tags: allowed_tags)
end
def tags_used
return [] if @content.blank?
cleaned_parsed = escape_liquid_tags_in_codeblock(@content)
tags = []
liquid_tag_options = { source: @source, user: @user }
Liquid::Template.parse(cleaned_parsed, liquid_tag_options).root.nodelist.each do |node|
tags << node.class if node.class.superclass.to_s == LiquidTagBase.to_s
end
tags.uniq
rescue Liquid::SyntaxError
[]
end
def catch_xss_attempts(markdown)
markdown_without_code_blocks = markdown.gsub(CODE_BLOCKS_REGEX, "")
return unless markdown_without_code_blocks.match?(Regexp.union(BAD_XSS_REGEX))
raise ArgumentError, I18n.t("services.markdown_processor.parser.invalid_markdown_detected")
end
def escape_liquid_tags_in_codeblock(content)
# Escape codeblocks, code spans, and inline code
content.gsub(/[[:space:]]*~{3}.*?~{3}|[[:space:]]*`{3}.*?`{3}|`{2}.+?`{2}|`{1}.+?`{1}/m) do |codeblock|
codeblock.gsub!("{% endraw %}", "{----% endraw %----}")
codeblock.gsub!("{% raw %}", "{----% raw %----}")
if codeblock.match?(/[[:space:]]*`{3}/)
"\n{% raw %}\n#{codeblock}\n{% endraw %}\n"
else
"{% raw %}#{codeblock}{% endraw %}"
end
end
end
def convert_code_tags_to_triple_backticks(content)
# return content if there is not a <code> tag
return content unless /^<code>$/.match?(content)
# return content if there is a <pre> and <code> tag
return content if content.include?("<code>") && content.include?("<pre>")
# Convert all multiline code tags to triple backticks
content.gsub(%r{^</?code>$}, "\n```\n")
end
private
def parse_html(html, prefix_images_options)
return html if html.blank?
Html::Parser
.new(html)
.remove_nested_linebreak_in_list
.prefix_all_images(**prefix_images_options)
.wrap_all_images_in_links
.add_control_class_to_codeblock
.add_control_panel_to_codeblock
.add_fullscreen_button_to_panel
.wrap_all_tables
.remove_empty_paragraphs
.escape_colon_emojis_in_codeblock
.unescape_raw_tag_in_codeblocks
.wrap_all_figures_with_tags
.wrap_mentions_with_links
.html
end
end
end