From 603fa7c14193d37e3953225501d2108f0c581df5 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Wed, 31 Jan 2018 21:48:18 +0000 Subject: Merge branch 'fix-mermaid-xss' into 'security-10-4' [10.4] Fix stored XSS in code blocks --- app/assets/javascripts/render_mermaid.js | 3 + .../unreleased/fix-stored-xss-in-code-blocks.yml | 5 + lib/banzai/filter/syntax_highlight_filter.rb | 34 +- spec/features/copy_as_gfm_spec.rb | 782 --------------------- spec/features/gitlab_flavored_markdown_spec.rb | 133 ---- spec/features/markdown/copy_as_gfm_spec.rb | 782 +++++++++++++++++++++ .../markdown/gitlab_flavored_markdown_spec.rb | 133 ++++ spec/features/markdown/markdown_spec.rb | 337 +++++++++ spec/features/markdown/math_spec.rb | 22 + spec/features/markdown/mermaid_spec.rb | 24 + spec/features/markdown_spec.rb | 337 --------- .../banzai/filter/syntax_highlight_filter_spec.rb | 57 +- 12 files changed, 1382 insertions(+), 1267 deletions(-) create mode 100644 changelogs/unreleased/fix-stored-xss-in-code-blocks.yml delete mode 100644 spec/features/copy_as_gfm_spec.rb delete mode 100644 spec/features/gitlab_flavored_markdown_spec.rb create mode 100644 spec/features/markdown/copy_as_gfm_spec.rb create mode 100644 spec/features/markdown/gitlab_flavored_markdown_spec.rb create mode 100644 spec/features/markdown/markdown_spec.rb create mode 100644 spec/features/markdown/math_spec.rb create mode 100644 spec/features/markdown/mermaid_spec.rb delete mode 100644 spec/features/markdown_spec.rb diff --git a/app/assets/javascripts/render_mermaid.js b/app/assets/javascripts/render_mermaid.js index 31c7a772cf4..d4f18955bd2 100644 --- a/app/assets/javascripts/render_mermaid.js +++ b/app/assets/javascripts/render_mermaid.js @@ -30,6 +30,9 @@ export default function renderMermaid($els) { $els.each((i, el) => { const source = el.textContent; + // Remove any extra spans added by the backend syntax highlighting. + Object.assign(el, { textContent: source }); + mermaid.init(undefined, el, (id) => { const svg = document.getElementById(id); diff --git a/changelogs/unreleased/fix-stored-xss-in-code-blocks.yml b/changelogs/unreleased/fix-stored-xss-in-code-blocks.yml new file mode 100644 index 00000000000..b595459ee6b --- /dev/null +++ b/changelogs/unreleased/fix-stored-xss-in-code-blocks.yml @@ -0,0 +1,5 @@ +--- +title: Fix stored XSS in code blocks that ignore highlighting +merge_request: +author: +type: security diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index a79a0154846..0ac7e231b5b 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -14,23 +14,33 @@ module Banzai end def highlight_node(node) - code = node.text css_classes = 'code highlight js-syntax-highlight' - language = node.attr('lang') + lang = node.attr('lang') + retried = false - if use_rouge?(language) - lexer = lexer_for(language) + if use_rouge?(lang) + lexer = lexer_for(lang) language = lexer.tag + else + lexer = Rouge::Lexers::PlainText.new + language = lang + end + + begin + code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, node.text), tag: language) + css_classes << " #{language}" if language + rescue + # Gracefully handle syntax highlighter bugs/errors to ensure users can + # still access an issue/comment/etc. First, retry with the plain text + # filter. If that fails, then just skip this entirely, but that would + # be a pretty bad upstream bug. + return if retried - begin - code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, code), tag: language) - css_classes << " #{language}" - rescue - # Gracefully handle syntax highlighter bugs/errors to ensure - # users can still access an issue/comment/etc. + language = nil + lexer = Rouge::Lexers::PlainText.new + retried = true - language = nil - end + retry end highlighted = %(
#{code}
) diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb deleted file mode 100644 index f82ed6300cc..00000000000 --- a/spec/features/copy_as_gfm_spec.rb +++ /dev/null @@ -1,782 +0,0 @@ -require 'spec_helper' - -describe 'Copy as GFM', :js do - include MarkupHelper - include RepoHelpers - include ActionView::Helpers::JavaScriptHelper - - before do - sign_in(create(:admin)) - end - - describe 'Copying rendered GFM' do - before do - @feat = MarkdownFeature.new - - # `markdown` helper expects a `@project` variable - @project = @feat.project - - visit project_issue_path(@project, @feat.issue) - end - - # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. - # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle - # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. - - # These are all in a single `it` for performance reasons. - it 'works', :aggregate_failures do - verify( - 'nesting', - - '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' - ) - - verify( - 'a real world example from the gitlab-ce README', - - <<-GFM.strip_heredoc - # GitLab - - [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) - [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) - [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) - [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) - - ## Canonical source - - The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). - - ## Open source software to collaborate on code - - To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). - - - Manage Git repositories with fine grained access controls that keep your code secure - - - Perform code reviews and enhance collaboration with merge requests - - - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications - - - Each project can also have an issue tracker, issue board, and a wiki - - - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises - - - Completely free and open source (MIT Expat license) - GFM - ) - - aggregate_failures('an accidentally selected empty element') do - gfm = '# Heading1' - - html = <<-HTML.strip_heredoc -

Heading1

- -

- HTML - - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - - aggregate_failures('an accidentally selected other element') do - gfm = 'Test comment with **Markdown!**' - - html = <<-HTML.strip_heredoc -
  • -
    -

    - Test comment with Markdown! -

    -
    -
  • - -
  • - HTML - - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - - verify( - 'InlineDiffFilter', - - '{-Deleted text-}', - '{+Added text+}' - ) - - verify( - 'TaskListFilter', - - '- [ ] Unchecked task', - '- [x] Checked task', - '1. [ ] Unchecked numbered task', - '1. [x] Checked numbered task' - ) - - verify( - 'ReferenceFilter', - - # issue reference - @feat.issue.to_reference, - # full issue reference - @feat.issue.to_reference(full: true), - # issue URL - project_issue_url(@project, @feat.issue), - # issue URL with note anchor - project_issue_url(@project, @feat.issue, anchor: 'note_123'), - # issue link - "[Issue](#{project_issue_url(@project, @feat.issue)})", - # issue link with note anchor - "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})" - ) - - verify( - 'AutolinkFilter', - - 'https://example.com' - ) - - verify( - 'TableOfContentsFilter', - - '[[_TOC_]]' - ) - - verify( - 'EmojiFilter', - - ':thumbsup:' - ) - - verify( - 'ImageLinkFilter', - - '![Image](https://example.com/image.png)' - ) - - verify( - 'VideoLinkFilter', - - '![Video](https://example.com/video.mp4)' - ) - - verify( - 'MathFilter: math as converted from GFM to HTML', - - '$`c = \pm\sqrt{a^2 + b^2}`$', - - # math block - <<-GFM.strip_heredoc - ```math - c = \pm\sqrt{a^2 + b^2} - ``` - GFM - ) - - aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do - gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' - - html = <<-HTML.strip_heredoc - - - - - - c - = - ± - - - - a - 2 - - + - - b - 2 - - - - - c = \\pm\\sqrt{a^2 + b^2} - - - - - - HTML - - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - - verify( - 'MermaidFilter: mermaid as converted from GFM to HTML', - - <<-GFM.strip_heredoc - ```mermaid - graph TD; - A-->B; - ``` - GFM - ) - - aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do - gfm = <<-GFM.strip_heredoc - ```mermaid - graph TD; - A-->B; - ``` - GFM - - html = <<-HTML.strip_heredoc - - - - - - - - - - - - - - - - - - - -
    - -
    -
    -
    -
    -
    - - - - - - -
    A
    -
    -
    -
    -
    - - - - - - -
    B
    -
    -
    -
    -
    -
    -
    -
    - graph TD; - A-->B; - -
    - HTML - - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - - verify( - 'SanitizationFilter', - - <<-GFM.strip_heredoc - sub - -
    -
    dt
    -
    dd
    -
    - - kbd - - q - - samp - - var - - ruby - - rt - - rp - - abbr - - summary - -
    details
    - GFM - ) - - verify( - 'SanitizationFilter', - - <<-GFM.strip_heredoc, - ``` - Plain text - ``` - GFM - - <<-GFM.strip_heredoc, - ```ruby - def foo - bar - end - ``` - GFM - - <<-GFM.strip_heredoc - Foo - - This is an example of GFM - - ```js - Code goes here - ``` - GFM - ) - - verify( - 'MarkdownFilter', - - "Line with two spaces at the end \nto insert a linebreak", - - '`code`', - '`` code with ` ticks ``', - - '> Quote', - - # multiline quote - <<-GFM.strip_heredoc, - > Multiline - > Quote - > - > With multiple paragraphs - GFM - - '![Image](https://example.com/image.png)', - - '# Heading with no anchor link', - - '[Link](https://example.com)', - - '- List item', - - # multiline list item - <<-GFM.strip_heredoc, - - Multiline - List item - GFM - - # nested lists - <<-GFM.strip_heredoc, - - Nested - - - Lists - GFM - - # list with blockquote - <<-GFM.strip_heredoc, - - List - - > Blockquote - GFM - - '1. Numbered list item', - - # multiline numbered list item - <<-GFM.strip_heredoc, - 1. Multiline - Numbered list item - GFM - - # nested numbered list - <<-GFM.strip_heredoc, - 1. Nested - - 1. Numbered lists - GFM - - '# Heading', - '## Heading', - '### Heading', - '#### Heading', - '##### Heading', - '###### Heading', - - '**Bold**', - - '_Italics_', - - '~~Strikethrough~~', - - '2^2', - - '-----', - - # table - <<-GFM.strip_heredoc, - | Centered | Right | Left | - |:--------:|------:|------| - | Foo | Bar | **Baz** | - | Foo | Bar | **Baz** | - GFM - - # table with empty heading - <<-GFM.strip_heredoc, - | | x | y | - |---|---|---| - | a | 1 | 0 | - | b | 0 | 1 | - GFM - ) - end - - alias_method :gfm_to_html, :markdown - - def verify(label, *gfms) - aggregate_failures(label) do - gfms.each do |gfm| - html = gfm_to_html(gfm).gsub(/\A | \z/, '') - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - end - end - - # Fake a `current_user` helper - def current_user - @feat.user - end - end - - describe 'Copying code' do - let(:project) { create(:project, :repository) } - - context 'from a diff' do - shared_examples 'copying code from a diff' do - context 'selecting one word of text' do - it 'copies as inline code' do - verify( - '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no', - - '`RuntimeError`', - - target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' - ) - end - end - - context 'selecting one line of text' do - it 'copies as inline code' do - verify( - '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]', - - '`raise RuntimeError, "System commands must be given as an array of strings"`', - - target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' - ) - end - end - - context 'selecting multiple lines of text' do - it 'copies as a code block' do - verify( - '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - - <<-GFM.strip_heredoc, - ```ruby - raise RuntimeError, "System commands must be given as an array of strings" - end - ``` - GFM - - target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' - ) - end - end - end - - context 'inline diff' do - before do - visit project_commit_path(project, sample_commit.id, view: 'inline') - end - - it_behaves_like 'copying code from a diff' - end - - context 'parallel diff' do - before do - visit project_commit_path(project, sample_commit.id, view: 'parallel') - end - - it_behaves_like 'copying code from a diff' - - context 'selecting code on the left' do - it 'copies as a code block' do - verify( - '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - - <<-GFM.strip_heredoc, - ```ruby - unless cmd.is_a?(Array) - raise "System commands must be given as an array of strings" - end - ``` - GFM - - target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side' - ) - end - end - - context 'selecting code on the right' do - it 'copies as a code block' do - verify( - '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', - - <<-GFM.strip_heredoc, - ```ruby - unless cmd.is_a?(Array) - raise RuntimeError, "System commands must be given as an array of strings" - end - ``` - GFM - - target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side' - ) - end - end - end - end - - context 'from a blob' do - before do - visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb')) - wait_for_requests - end - - context 'selecting one word of text' do - it 'copies as inline code' do - verify( - '.line[id="LC9"] .no', - - '`RuntimeError`' - ) - end - end - - context 'selecting one line of text' do - it 'copies as inline code' do - verify( - '.line[id="LC9"]', - - '`raise RuntimeError, "System commands must be given as an array of strings"`' - ) - end - end - - context 'selecting multiple lines of text' do - it 'copies as a code block' do - verify( - '.line[id="LC9"], .line[id="LC10"]', - - <<-GFM.strip_heredoc, - ```ruby - raise RuntimeError, "System commands must be given as an array of strings" - end - ``` - GFM - ) - end - end - end - - context 'from a GFM code block' do - before do - visit project_blob_path(project, File.join('markdown', 'doc/api/users.md')) - wait_for_requests - end - - context 'selecting one word of text' do - it 'copies as inline code' do - verify( - '.line[id="LC27"] .s2', - - '`"bio"`' - ) - end - end - - context 'selecting one line of text' do - it 'copies as inline code' do - verify( - '.line[id="LC27"]', - - '`"bio": null,`' - ) - end - end - - context 'selecting multiple lines of text' do - it 'copies as a code block with the correct language' do - verify( - '.line[id="LC27"], .line[id="LC28"]', - - <<-GFM.strip_heredoc, - ```json - "bio": null, - "skype": "", - ``` - GFM - ) - end - end - end - - def verify(selector, gfm, target: nil) - html = html_for_selector(selector) - output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target) - expect(output_gfm.strip).to eq(gfm.strip) - end - end - - def html_for_selector(selector) - js = <<-JS.strip_heredoc - (function(selector) { - var els = document.querySelectorAll(selector); - var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; }); - return htmls.join("\\n"); - })("#{escape_javascript(selector)}") - JS - page.evaluate_script(js) - end - - def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) - js = <<-JS.strip_heredoc - (function(html) { - var transformer = window.CopyAsGFM[#{transformer.inspect}]; - - var node = document.createElement('div'); - $(html).each(function() { node.appendChild(this) }); - - var targetSelector = #{target.to_json}; - var target; - if (targetSelector) { - target = document.querySelector(targetSelector); - } - - node = transformer(node, target); - if (!node) return null; - - return window.CopyAsGFM.nodeToGFM(node); - })("#{escape_javascript(html)}") - JS - page.evaluate_script(js) - end -end diff --git a/spec/features/gitlab_flavored_markdown_spec.rb b/spec/features/gitlab_flavored_markdown_spec.rb deleted file mode 100644 index 3c2186b3598..00000000000 --- a/spec/features/gitlab_flavored_markdown_spec.rb +++ /dev/null @@ -1,133 +0,0 @@ -require 'spec_helper' - -describe "GitLab Flavored Markdown" do - let(:user) { create(:user) } - let(:project) { create(:project) } - let(:issue) { create(:issue, project: project) } - let(:fred) do - create(:user, name: 'fred') do |user| - project.add_master(user) - end - end - - before do - sign_in(user) - project.add_developer(user) - end - - describe "for commits" do - let(:project) { create(:project, :repository) } - let(:commit) { project.commit } - - before do - allow_any_instance_of(Commit).to receive(:title) - .and_return("fix #{issue.to_reference}\n\nask #{fred.to_reference} for details") - end - - it "renders title in commits#index" do - visit project_commits_path(project, 'master', limit: 1) - - expect(page).to have_link(issue.to_reference) - end - - it "renders title in commits#show" do - visit project_commit_path(project, commit) - - expect(page).to have_link(issue.to_reference) - end - - it "renders description in commits#show" do - visit project_commit_path(project, commit) - - expect(page).to have_link(fred.to_reference) - end - - it "renders title in repositories#branches" do - visit project_branches_path(project) - - expect(page).to have_link(issue.to_reference) - end - end - - describe "for issues", :js do - before do - @other_issue = create(:issue, - author: user, - assignees: [user], - project: project) - @issue = create(:issue, - author: user, - assignees: [user], - project: project, - title: "fix #{@other_issue.to_reference}", - description: "ask #{fred.to_reference} for details") - - @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world") - end - - it "renders subject in issues#index" do - visit project_issues_path(project) - - expect(page).to have_link(@other_issue.to_reference) - end - - it "renders subject in issues#show" do - visit project_issue_path(project, @issue) - - expect(page).to have_link(@other_issue.to_reference) - end - - it "renders details in issues#show" do - visit project_issue_path(project, @issue) - - expect(page).to have_link(fred.to_reference) - end - end - - describe "for merge requests" do - let(:project) { create(:project, :repository) } - - before do - @merge_request = create(:merge_request, source_project: project, target_project: project, title: "fix #{issue.to_reference}") - end - - it "renders title in merge_requests#index" do - visit project_merge_requests_path(project) - - expect(page).to have_link(issue.to_reference) - end - - it "renders title in merge_requests#show" do - visit project_merge_request_path(project, @merge_request) - - expect(page).to have_link(issue.to_reference) - end - end - - describe "for milestones" do - before do - @milestone = create(:milestone, - project: project, - title: "fix #{issue.to_reference}", - description: "ask #{fred.to_reference} for details") - end - - it "renders title in milestones#index" do - visit project_milestones_path(project) - - expect(page).to have_link(issue.to_reference) - end - - it "renders title in milestones#show" do - visit project_milestone_path(project, @milestone) - - expect(page).to have_link(issue.to_reference) - end - - it "renders description in milestones#show" do - visit project_milestone_path(project, @milestone) - - expect(page).to have_link(fred.to_reference) - end - end -end diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb new file mode 100644 index 00000000000..f82ed6300cc --- /dev/null +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -0,0 +1,782 @@ +require 'spec_helper' + +describe 'Copy as GFM', :js do + include MarkupHelper + include RepoHelpers + include ActionView::Helpers::JavaScriptHelper + + before do + sign_in(create(:admin)) + end + + describe 'Copying rendered GFM' do + before do + @feat = MarkdownFeature.new + + # `markdown` helper expects a `@project` variable + @project = @feat.project + + visit project_issue_path(@project, @feat.issue) + end + + # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. + # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle + # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. + + # These are all in a single `it` for performance reasons. + it 'works', :aggregate_failures do + verify( + 'nesting', + + '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + ) + + verify( + 'a real world example from the gitlab-ce README', + + <<-GFM.strip_heredoc + # GitLab + + [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) + + ## Canonical source + + The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). + + ## Open source software to collaborate on code + + To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). + + - Manage Git repositories with fine grained access controls that keep your code secure + + - Perform code reviews and enhance collaboration with merge requests + + - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + + - Each project can also have an issue tracker, issue board, and a wiki + + - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + + - Completely free and open source (MIT Expat license) + GFM + ) + + aggregate_failures('an accidentally selected empty element') do + gfm = '# Heading1' + + html = <<-HTML.strip_heredoc +

    Heading1

    + +

    + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + aggregate_failures('an accidentally selected other element') do + gfm = 'Test comment with **Markdown!**' + + html = <<-HTML.strip_heredoc +
  • +
    +

    + Test comment with Markdown! +

    +
    +
  • + +
  • + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'InlineDiffFilter', + + '{-Deleted text-}', + '{+Added text+}' + ) + + verify( + 'TaskListFilter', + + '- [ ] Unchecked task', + '- [x] Checked task', + '1. [ ] Unchecked numbered task', + '1. [x] Checked numbered task' + ) + + verify( + 'ReferenceFilter', + + # issue reference + @feat.issue.to_reference, + # full issue reference + @feat.issue.to_reference(full: true), + # issue URL + project_issue_url(@project, @feat.issue), + # issue URL with note anchor + project_issue_url(@project, @feat.issue, anchor: 'note_123'), + # issue link + "[Issue](#{project_issue_url(@project, @feat.issue)})", + # issue link with note anchor + "[Issue](#{project_issue_url(@project, @feat.issue, anchor: 'note_123')})" + ) + + verify( + 'AutolinkFilter', + + 'https://example.com' + ) + + verify( + 'TableOfContentsFilter', + + '[[_TOC_]]' + ) + + verify( + 'EmojiFilter', + + ':thumbsup:' + ) + + verify( + 'ImageLinkFilter', + + '![Image](https://example.com/image.png)' + ) + + verify( + 'VideoLinkFilter', + + '![Video](https://example.com/video.mp4)' + ) + + verify( + 'MathFilter: math as converted from GFM to HTML', + + '$`c = \pm\sqrt{a^2 + b^2}`$', + + # math block + <<-GFM.strip_heredoc + ```math + c = \pm\sqrt{a^2 + b^2} + ``` + GFM + ) + + aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do + gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' + + html = <<-HTML.strip_heredoc + + + + + + c + = + ± + + + + a + 2 + + + + + b + 2 + + + + + c = \\pm\\sqrt{a^2 + b^2} + + + + + + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'MermaidFilter: mermaid as converted from GFM to HTML', + + <<-GFM.strip_heredoc + ```mermaid + graph TD; + A-->B; + ``` + GFM + ) + + aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do + gfm = <<-GFM.strip_heredoc + ```mermaid + graph TD; + A-->B; + ``` + GFM + + html = <<-HTML.strip_heredoc + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +
    +
    + + + + + + +
    A
    +
    +
    +
    +
    + + + + + + +
    B
    +
    +
    +
    +
    +
    +
    +
    + graph TD; + A-->B; + +
    + HTML + + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + + verify( + 'SanitizationFilter', + + <<-GFM.strip_heredoc + sub + +
    +
    dt
    +
    dd
    +
    + + kbd + + q + + samp + + var + + ruby + + rt + + rp + + abbr + + summary + +
    details
    + GFM + ) + + verify( + 'SanitizationFilter', + + <<-GFM.strip_heredoc, + ``` + Plain text + ``` + GFM + + <<-GFM.strip_heredoc, + ```ruby + def foo + bar + end + ``` + GFM + + <<-GFM.strip_heredoc + Foo + + This is an example of GFM + + ```js + Code goes here + ``` + GFM + ) + + verify( + 'MarkdownFilter', + + "Line with two spaces at the end \nto insert a linebreak", + + '`code`', + '`` code with ` ticks ``', + + '> Quote', + + # multiline quote + <<-GFM.strip_heredoc, + > Multiline + > Quote + > + > With multiple paragraphs + GFM + + '![Image](https://example.com/image.png)', + + '# Heading with no anchor link', + + '[Link](https://example.com)', + + '- List item', + + # multiline list item + <<-GFM.strip_heredoc, + - Multiline + List item + GFM + + # nested lists + <<-GFM.strip_heredoc, + - Nested + + - Lists + GFM + + # list with blockquote + <<-GFM.strip_heredoc, + - List + + > Blockquote + GFM + + '1. Numbered list item', + + # multiline numbered list item + <<-GFM.strip_heredoc, + 1. Multiline + Numbered list item + GFM + + # nested numbered list + <<-GFM.strip_heredoc, + 1. Nested + + 1. Numbered lists + GFM + + '# Heading', + '## Heading', + '### Heading', + '#### Heading', + '##### Heading', + '###### Heading', + + '**Bold**', + + '_Italics_', + + '~~Strikethrough~~', + + '2^2', + + '-----', + + # table + <<-GFM.strip_heredoc, + | Centered | Right | Left | + |:--------:|------:|------| + | Foo | Bar | **Baz** | + | Foo | Bar | **Baz** | + GFM + + # table with empty heading + <<-GFM.strip_heredoc, + | | x | y | + |---|---|---| + | a | 1 | 0 | + | b | 0 | 1 | + GFM + ) + end + + alias_method :gfm_to_html, :markdown + + def verify(label, *gfms) + aggregate_failures(label) do + gfms.each do |gfm| + html = gfm_to_html(gfm).gsub(/\A | \z/, '') + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + end + end + + # Fake a `current_user` helper + def current_user + @feat.user + end + end + + describe 'Copying code' do + let(:project) { create(:project, :repository) } + + context 'from a diff' do + shared_examples 'copying code from a diff' do + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no', + + '`RuntimeError`', + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]', + + '`raise RuntimeError, "System commands must be given as an array of strings"`', + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]' + ) + end + end + end + + context 'inline diff' do + before do + visit project_commit_path(project, sample_commit.id, view: 'inline') + end + + it_behaves_like 'copying code from a diff' + end + + context 'parallel diff' do + before do + visit project_commit_path(project, sample_commit.id, view: 'parallel') + end + + it_behaves_like 'copying code from a diff' + + context 'selecting code on the left' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + unless cmd.is_a?(Array) + raise "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].left-side' + ) + end + end + + context 'selecting code on the right' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + unless cmd.is_a?(Array) + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + + target: '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"].right-side' + ) + end + end + end + end + + context 'from a blob' do + before do + visit project_blob_path(project, File.join('master', 'files/ruby/popen.rb')) + wait_for_requests + end + + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"] .no', + + '`RuntimeError`' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"]', + + '`raise RuntimeError, "System commands must be given as an array of strings"`' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '.line[id="LC9"], .line[id="LC10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + ) + end + end + end + + context 'from a GFM code block' do + before do + visit project_blob_path(project, File.join('markdown', 'doc/api/users.md')) + wait_for_requests + end + + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"] .s2', + + '`"bio"`' + ) + end + end + + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"]', + + '`"bio": null,`' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block with the correct language' do + verify( + '.line[id="LC27"], .line[id="LC28"]', + + <<-GFM.strip_heredoc, + ```json + "bio": null, + "skype": "", + ``` + GFM + ) + end + end + end + + def verify(selector, gfm, target: nil) + html = html_for_selector(selector) + output_gfm = html_to_gfm(html, 'transformCodeSelection', target: target) + expect(output_gfm.strip).to eq(gfm.strip) + end + end + + def html_for_selector(selector) + js = <<-JS.strip_heredoc + (function(selector) { + var els = document.querySelectorAll(selector); + var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; }); + return htmls.join("\\n"); + })("#{escape_javascript(selector)}") + JS + page.evaluate_script(js) + end + + def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) + js = <<-JS.strip_heredoc + (function(html) { + var transformer = window.CopyAsGFM[#{transformer.inspect}]; + + var node = document.createElement('div'); + $(html).each(function() { node.appendChild(this) }); + + var targetSelector = #{target.to_json}; + var target; + if (targetSelector) { + target = document.querySelector(targetSelector); + } + + node = transformer(node, target); + if (!node) return null; + + return window.CopyAsGFM.nodeToGFM(node); + })("#{escape_javascript(html)}") + JS + page.evaluate_script(js) + end +end diff --git a/spec/features/markdown/gitlab_flavored_markdown_spec.rb b/spec/features/markdown/gitlab_flavored_markdown_spec.rb new file mode 100644 index 00000000000..3c2186b3598 --- /dev/null +++ b/spec/features/markdown/gitlab_flavored_markdown_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe "GitLab Flavored Markdown" do + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + let(:fred) do + create(:user, name: 'fred') do |user| + project.add_master(user) + end + end + + before do + sign_in(user) + project.add_developer(user) + end + + describe "for commits" do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit } + + before do + allow_any_instance_of(Commit).to receive(:title) + .and_return("fix #{issue.to_reference}\n\nask #{fred.to_reference} for details") + end + + it "renders title in commits#index" do + visit project_commits_path(project, 'master', limit: 1) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in commits#show" do + visit project_commit_path(project, commit) + + expect(page).to have_link(issue.to_reference) + end + + it "renders description in commits#show" do + visit project_commit_path(project, commit) + + expect(page).to have_link(fred.to_reference) + end + + it "renders title in repositories#branches" do + visit project_branches_path(project) + + expect(page).to have_link(issue.to_reference) + end + end + + describe "for issues", :js do + before do + @other_issue = create(:issue, + author: user, + assignees: [user], + project: project) + @issue = create(:issue, + author: user, + assignees: [user], + project: project, + title: "fix #{@other_issue.to_reference}", + description: "ask #{fred.to_reference} for details") + + @note = create(:note_on_issue, noteable: @issue, project: @issue.project, note: "Hello world") + end + + it "renders subject in issues#index" do + visit project_issues_path(project) + + expect(page).to have_link(@other_issue.to_reference) + end + + it "renders subject in issues#show" do + visit project_issue_path(project, @issue) + + expect(page).to have_link(@other_issue.to_reference) + end + + it "renders details in issues#show" do + visit project_issue_path(project, @issue) + + expect(page).to have_link(fred.to_reference) + end + end + + describe "for merge requests" do + let(:project) { create(:project, :repository) } + + before do + @merge_request = create(:merge_request, source_project: project, target_project: project, title: "fix #{issue.to_reference}") + end + + it "renders title in merge_requests#index" do + visit project_merge_requests_path(project) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in merge_requests#show" do + visit project_merge_request_path(project, @merge_request) + + expect(page).to have_link(issue.to_reference) + end + end + + describe "for milestones" do + before do + @milestone = create(:milestone, + project: project, + title: "fix #{issue.to_reference}", + description: "ask #{fred.to_reference} for details") + end + + it "renders title in milestones#index" do + visit project_milestones_path(project) + + expect(page).to have_link(issue.to_reference) + end + + it "renders title in milestones#show" do + visit project_milestone_path(project, @milestone) + + expect(page).to have_link(issue.to_reference) + end + + it "renders description in milestones#show" do + visit project_milestone_path(project, @milestone) + + expect(page).to have_link(fred.to_reference) + end + end +end diff --git a/spec/features/markdown/markdown_spec.rb b/spec/features/markdown/markdown_spec.rb new file mode 100644 index 00000000000..f13d78d24e3 --- /dev/null +++ b/spec/features/markdown/markdown_spec.rb @@ -0,0 +1,337 @@ +require 'spec_helper' +require 'erb' + +# This feature spec is intended to be a comprehensive exercising of all of +# GitLab's non-standard Markdown parsing and the integration thereof. +# +# These tests should be very high-level. Anything low-level belongs in the specs +# for the corresponding HTML::Pipeline filter or helper method. +# +# The idea is to pass a Markdown document through our entire processing stack. +# +# The process looks like this: +# +# Raw Markdown +# -> `markdown` helper +# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML +# -> Post-process HTML +# -> `gfm` helper +# -> HTML::Pipeline +# -> SanitizationFilter +# -> Other filters, depending on pipeline +# -> `html_safe` +# -> Template +# +# See the MarkdownFeature class for setup details. + +describe 'GitLab Markdown' do + include Capybara::Node::Matchers + include MarkupHelper + include MarkdownMatchers + + # Sometimes it can be useful to see the parsed output of the Markdown document + # for debugging. Call this method to write the output to + # `tmp/capybara/.html`. + def write_markdown(filename = 'markdown_spec') + File.open(Rails.root.join("tmp/capybara/#{filename}.html"), 'w') do |file| + file.puts @html + end + end + + def doc(html = @html) + @doc ||= Nokogiri::HTML::DocumentFragment.parse(html) + end + + # Shared behavior that all pipelines should exhibit + shared_examples 'all pipelines' do + describe 'Redcarpet extensions' do + it 'does not parse emphasis inside of words' do + expect(doc.to_html).not_to match('foobarbaz') + end + + it 'parses table Markdown' do + aggregate_failures do + expect(doc).to have_selector('th:contains("Header")') + expect(doc).to have_selector('th:contains("Row")') + expect(doc).to have_selector('th:contains("Example")') + end + end + + it 'allows Markdown in tables' do + expect(doc.at_css('td:contains("Baz")').children.to_html) + .to eq 'Baz' + end + + it 'parses fenced code blocks' do + aggregate_failures do + expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.c') + expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.python') + end + end + + it 'parses mermaid code block' do + aggregate_failures do + expect(doc).to have_selector('pre[lang=mermaid] > code.js-render-mermaid') + end + end + + it 'parses strikethroughs' do + expect(doc).to have_selector(%{del:contains("and this text doesn't")}) + end + + it 'parses superscript' do + expect(doc).to have_selector('sup', count: 2) + end + end + + describe 'SanitizationFilter' do + it 'permits b elements' do + expect(doc).to have_selector('b:contains("b tag")') + end + + it 'permits em elements' do + expect(doc).to have_selector('em:contains("em tag")') + end + + it 'permits code elements' do + expect(doc).to have_selector('code:contains("code tag")') + end + + it 'permits kbd elements' do + expect(doc).to have_selector('kbd:contains("s")') + end + + it 'permits strike elements' do + expect(doc).to have_selector('strike:contains(Emoji)') + end + + it 'permits img elements' do + expect(doc).to have_selector('img[data-src*="smile.png"]') + end + + it 'permits br elements' do + expect(doc).to have_selector('br') + end + + it 'permits hr elements' do + expect(doc).to have_selector('hr') + end + + it 'permits span elements' do + expect(doc).to have_selector('span:contains("span tag")') + end + + it 'permits details elements' do + expect(doc).to have_selector('details:contains("Hiding the details")') + end + + it 'permits summary elements' do + expect(doc).to have_selector('details summary:contains("collapsible")') + end + + it 'permits style attribute in th elements' do + aggregate_failures do + expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center' + expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right' + expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left' + end + end + + it 'permits style attribute in td elements' do + aggregate_failures do + expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center' + expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right' + expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left' + end + end + + it 'removes `rel` attribute from links' do + expect(doc).not_to have_selector('a[rel="bookmark"]') + end + + it "removes `href` from `a` elements if it's fishy" do + expect(doc).not_to have_selector('a[href*="javascript"]') + end + end + + describe 'Escaping' do + it 'escapes non-tag angle brackets' do + table = doc.css('table').last.at_css('tbody') + expect(table.at_xpath('.//tr[1]/td[3]').inner_html).to eq '1 < 3 & 5' + end + end + + describe 'Edge Cases' do + it 'allows markup inside link elements' do + aggregate_failures do + expect(doc.at_css('a[href="#link-emphasis"]').to_html) + .to eq %{text} + + expect(doc.at_css('a[href="#link-strong"]').to_html) + .to eq %{text} + + expect(doc.at_css('a[href="#link-code"]').to_html) + .to eq %{text} + end + end + end + + describe 'ExternalLinkFilter' do + it 'adds nofollow to external link' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('rel')).to include('nofollow') + end + + it 'adds noreferrer to external link' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('rel')).to include('noreferrer') + end + + it 'adds _blank to target attribute for external links' do + link = doc.at_css('a:contains("Google")') + + expect(link.attr('target')).to match('_blank') + end + + it 'ignores internal link' do + link = doc.at_css('a:contains("GitLab Root")') + + expect(link.attr('rel')).not_to match 'nofollow' + expect(link.attr('target')).not_to match '_blank' + end + end + end + + before do + @feat = MarkdownFeature.new + + # `markdown` helper expects a `@project` and `@group` variable + @project = @feat.project + @group = @feat.group + end + + context 'default pipeline' do + before do + @html = markdown(@feat.raw_markdown) + end + + it_behaves_like 'all pipelines' + + it 'includes RelativeLinkFilter' do + expect(doc).to parse_relative_links + end + + it 'includes EmojiFilter' do + expect(doc).to parse_emoji + end + + it 'includes TableOfContentsFilter' do + expect(doc).to create_header_links + end + + it 'includes AutolinkFilter' do + expect(doc).to create_autolinks + end + + it 'includes all reference filters' do + aggregate_failures do + expect(doc).to reference_users + expect(doc).to reference_issues + expect(doc).to reference_merge_requests + expect(doc).to reference_snippets + expect(doc).to reference_commit_ranges + expect(doc).to reference_commits + expect(doc).to reference_labels + expect(doc).to reference_milestones + end + end + + it 'includes TaskListFilter' do + expect(doc).to parse_task_lists + end + + it 'includes InlineDiffFilter' do + expect(doc).to parse_inline_diffs + end + + it 'includes VideoLinkFilter' do + expect(doc).to parse_video_links + end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end + end + + context 'wiki pipeline' do + before do + @project_wiki = @feat.project_wiki + @project_wiki_page = @feat.project_wiki_page + + file = Gollum::File.new(@project_wiki.wiki) + expect(file).to receive(:path).and_return('images/example.jpg') + expect(@project_wiki).to receive(:find_file).with('images/example.jpg').and_return(file) + allow(@project_wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' } + + @html = markdown(@feat.raw_markdown, { pipeline: :wiki, project_wiki: @project_wiki, page_slug: @project_wiki_page.slug }) + end + + it_behaves_like 'all pipelines' + + it 'includes RelativeLinkFilter' do + expect(doc).not_to parse_relative_links + end + + it 'includes EmojiFilter' do + expect(doc).to parse_emoji + end + + it 'includes TableOfContentsFilter' do + expect(doc).to create_header_links + end + + it 'includes AutolinkFilter' do + expect(doc).to create_autolinks + end + + it 'includes all reference filters' do + aggregate_failures do + expect(doc).to reference_users + expect(doc).to reference_issues + expect(doc).to reference_merge_requests + expect(doc).to reference_snippets + expect(doc).to reference_commit_ranges + expect(doc).to reference_commits + expect(doc).to reference_labels + expect(doc).to reference_milestones + end + end + + it 'includes TaskListFilter' do + expect(doc).to parse_task_lists + end + + it 'includes GollumTagsFilter' do + expect(doc).to parse_gollum_tags + end + + it 'includes InlineDiffFilter' do + expect(doc).to parse_inline_diffs + end + + it 'includes VideoLinkFilter' do + expect(doc).to parse_video_links + end + + it 'includes ColorFilter' do + expect(doc).to parse_colors + end + end + + # Fake a `current_user` helper + def current_user + @feat.user + end +end diff --git a/spec/features/markdown/math_spec.rb b/spec/features/markdown/math_spec.rb new file mode 100644 index 00000000000..6a23d6b78ab --- /dev/null +++ b/spec/features/markdown/math_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe 'Math rendering', :js do + it 'renders inline and display math correctly' do + description = <<~MATH + This math is inline $`a^2+b^2=c^2`$. + + This is on a separate line + ```math + a^2+b^2=c^2 + ``` + MATH + + project = create(:project, :public) + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + expect(page).to have_selector('.katex .mord.mathit', text: 'b') + expect(page).to have_selector('.katex-display .mord.mathit', text: 'b') + end +end diff --git a/spec/features/markdown/mermaid_spec.rb b/spec/features/markdown/mermaid_spec.rb new file mode 100644 index 00000000000..a25d701ee35 --- /dev/null +++ b/spec/features/markdown/mermaid_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe 'Mermaid rendering', :js do + it 'renders Mermaid diagrams correctly' do + description = <<~MERMAID + ```mermaid + graph TD; + A-->B; + A-->C; + B-->D; + C-->D; + ``` + MERMAID + + project = create(:project, :public) + issue = create(:issue, project: project, description: description) + + visit project_issue_path(project, issue) + + %w[A B C D].each do |label| + expect(page).to have_selector('svg foreignObject', text: label) + end + end +end diff --git a/spec/features/markdown_spec.rb b/spec/features/markdown_spec.rb deleted file mode 100644 index f13d78d24e3..00000000000 --- a/spec/features/markdown_spec.rb +++ /dev/null @@ -1,337 +0,0 @@ -require 'spec_helper' -require 'erb' - -# This feature spec is intended to be a comprehensive exercising of all of -# GitLab's non-standard Markdown parsing and the integration thereof. -# -# These tests should be very high-level. Anything low-level belongs in the specs -# for the corresponding HTML::Pipeline filter or helper method. -# -# The idea is to pass a Markdown document through our entire processing stack. -# -# The process looks like this: -# -# Raw Markdown -# -> `markdown` helper -# -> Redcarpet::Render::GitlabHTML converts Markdown to HTML -# -> Post-process HTML -# -> `gfm` helper -# -> HTML::Pipeline -# -> SanitizationFilter -# -> Other filters, depending on pipeline -# -> `html_safe` -# -> Template -# -# See the MarkdownFeature class for setup details. - -describe 'GitLab Markdown' do - include Capybara::Node::Matchers - include MarkupHelper - include MarkdownMatchers - - # Sometimes it can be useful to see the parsed output of the Markdown document - # for debugging. Call this method to write the output to - # `tmp/capybara/.html`. - def write_markdown(filename = 'markdown_spec') - File.open(Rails.root.join("tmp/capybara/#{filename}.html"), 'w') do |file| - file.puts @html - end - end - - def doc(html = @html) - @doc ||= Nokogiri::HTML::DocumentFragment.parse(html) - end - - # Shared behavior that all pipelines should exhibit - shared_examples 'all pipelines' do - describe 'Redcarpet extensions' do - it 'does not parse emphasis inside of words' do - expect(doc.to_html).not_to match('foobarbaz') - end - - it 'parses table Markdown' do - aggregate_failures do - expect(doc).to have_selector('th:contains("Header")') - expect(doc).to have_selector('th:contains("Row")') - expect(doc).to have_selector('th:contains("Example")') - end - end - - it 'allows Markdown in tables' do - expect(doc.at_css('td:contains("Baz")').children.to_html) - .to eq 'Baz' - end - - it 'parses fenced code blocks' do - aggregate_failures do - expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.c') - expect(doc).to have_selector('pre.code.highlight.js-syntax-highlight.python') - end - end - - it 'parses mermaid code block' do - aggregate_failures do - expect(doc).to have_selector('pre[lang=mermaid] > code.js-render-mermaid') - end - end - - it 'parses strikethroughs' do - expect(doc).to have_selector(%{del:contains("and this text doesn't")}) - end - - it 'parses superscript' do - expect(doc).to have_selector('sup', count: 2) - end - end - - describe 'SanitizationFilter' do - it 'permits b elements' do - expect(doc).to have_selector('b:contains("b tag")') - end - - it 'permits em elements' do - expect(doc).to have_selector('em:contains("em tag")') - end - - it 'permits code elements' do - expect(doc).to have_selector('code:contains("code tag")') - end - - it 'permits kbd elements' do - expect(doc).to have_selector('kbd:contains("s")') - end - - it 'permits strike elements' do - expect(doc).to have_selector('strike:contains(Emoji)') - end - - it 'permits img elements' do - expect(doc).to have_selector('img[data-src*="smile.png"]') - end - - it 'permits br elements' do - expect(doc).to have_selector('br') - end - - it 'permits hr elements' do - expect(doc).to have_selector('hr') - end - - it 'permits span elements' do - expect(doc).to have_selector('span:contains("span tag")') - end - - it 'permits details elements' do - expect(doc).to have_selector('details:contains("Hiding the details")') - end - - it 'permits summary elements' do - expect(doc).to have_selector('details summary:contains("collapsible")') - end - - it 'permits style attribute in th elements' do - aggregate_failures do - expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center' - expect(doc.at_css('th:contains("Row")')['style']).to eq 'text-align: right' - expect(doc.at_css('th:contains("Example")')['style']).to eq 'text-align: left' - end - end - - it 'permits style attribute in td elements' do - aggregate_failures do - expect(doc.at_css('td:contains("Foo")')['style']).to eq 'text-align: center' - expect(doc.at_css('td:contains("Bar")')['style']).to eq 'text-align: right' - expect(doc.at_css('td:contains("Baz")')['style']).to eq 'text-align: left' - end - end - - it 'removes `rel` attribute from links' do - expect(doc).not_to have_selector('a[rel="bookmark"]') - end - - it "removes `href` from `a` elements if it's fishy" do - expect(doc).not_to have_selector('a[href*="javascript"]') - end - end - - describe 'Escaping' do - it 'escapes non-tag angle brackets' do - table = doc.css('table').last.at_css('tbody') - expect(table.at_xpath('.//tr[1]/td[3]').inner_html).to eq '1 < 3 & 5' - end - end - - describe 'Edge Cases' do - it 'allows markup inside link elements' do - aggregate_failures do - expect(doc.at_css('a[href="#link-emphasis"]').to_html) - .to eq %{text} - - expect(doc.at_css('a[href="#link-strong"]').to_html) - .to eq %{text} - - expect(doc.at_css('a[href="#link-code"]').to_html) - .to eq %{text} - end - end - end - - describe 'ExternalLinkFilter' do - it 'adds nofollow to external link' do - link = doc.at_css('a:contains("Google")') - - expect(link.attr('rel')).to include('nofollow') - end - - it 'adds noreferrer to external link' do - link = doc.at_css('a:contains("Google")') - - expect(link.attr('rel')).to include('noreferrer') - end - - it 'adds _blank to target attribute for external links' do - link = doc.at_css('a:contains("Google")') - - expect(link.attr('target')).to match('_blank') - end - - it 'ignores internal link' do - link = doc.at_css('a:contains("GitLab Root")') - - expect(link.attr('rel')).not_to match 'nofollow' - expect(link.attr('target')).not_to match '_blank' - end - end - end - - before do - @feat = MarkdownFeature.new - - # `markdown` helper expects a `@project` and `@group` variable - @project = @feat.project - @group = @feat.group - end - - context 'default pipeline' do - before do - @html = markdown(@feat.raw_markdown) - end - - it_behaves_like 'all pipelines' - - it 'includes RelativeLinkFilter' do - expect(doc).to parse_relative_links - end - - it 'includes EmojiFilter' do - expect(doc).to parse_emoji - end - - it 'includes TableOfContentsFilter' do - expect(doc).to create_header_links - end - - it 'includes AutolinkFilter' do - expect(doc).to create_autolinks - end - - it 'includes all reference filters' do - aggregate_failures do - expect(doc).to reference_users - expect(doc).to reference_issues - expect(doc).to reference_merge_requests - expect(doc).to reference_snippets - expect(doc).to reference_commit_ranges - expect(doc).to reference_commits - expect(doc).to reference_labels - expect(doc).to reference_milestones - end - end - - it 'includes TaskListFilter' do - expect(doc).to parse_task_lists - end - - it 'includes InlineDiffFilter' do - expect(doc).to parse_inline_diffs - end - - it 'includes VideoLinkFilter' do - expect(doc).to parse_video_links - end - - it 'includes ColorFilter' do - expect(doc).to parse_colors - end - end - - context 'wiki pipeline' do - before do - @project_wiki = @feat.project_wiki - @project_wiki_page = @feat.project_wiki_page - - file = Gollum::File.new(@project_wiki.wiki) - expect(file).to receive(:path).and_return('images/example.jpg') - expect(@project_wiki).to receive(:find_file).with('images/example.jpg').and_return(file) - allow(@project_wiki).to receive(:wiki_base_path) { '/namespace1/gitlabhq/wikis' } - - @html = markdown(@feat.raw_markdown, { pipeline: :wiki, project_wiki: @project_wiki, page_slug: @project_wiki_page.slug }) - end - - it_behaves_like 'all pipelines' - - it 'includes RelativeLinkFilter' do - expect(doc).not_to parse_relative_links - end - - it 'includes EmojiFilter' do - expect(doc).to parse_emoji - end - - it 'includes TableOfContentsFilter' do - expect(doc).to create_header_links - end - - it 'includes AutolinkFilter' do - expect(doc).to create_autolinks - end - - it 'includes all reference filters' do - aggregate_failures do - expect(doc).to reference_users - expect(doc).to reference_issues - expect(doc).to reference_merge_requests - expect(doc).to reference_snippets - expect(doc).to reference_commit_ranges - expect(doc).to reference_commits - expect(doc).to reference_labels - expect(doc).to reference_milestones - end - end - - it 'includes TaskListFilter' do - expect(doc).to parse_task_lists - end - - it 'includes GollumTagsFilter' do - expect(doc).to parse_gollum_tags - end - - it 'includes InlineDiffFilter' do - expect(doc).to parse_inline_diffs - end - - it 'includes VideoLinkFilter' do - expect(doc).to parse_video_links - end - - it 'includes ColorFilter' do - expect(doc).to parse_colors - end - end - - # Fake a `current_user` helper - def current_user - @feat.user - end -end diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index 9f2efa05a01..ef52c572898 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -3,35 +3,86 @@ require 'spec_helper' describe Banzai::Filter::SyntaxHighlightFilter do include FilterSpecHelper + shared_examples "XSS prevention" do |lang| + it "escapes HTML tags" do + # This is how a script tag inside a code block is presented to this filter + # after Markdown rendering. + result = filter(%{
    <script>alert(1)</script>
    }) + + expect(result.to_html).not_to include("") + expect(result.to_html).to include("alert(1)") + end + end + context "when no language is specified" do it "highlights as plaintext" do result = filter('
    def fun end
    ') + expect(result.to_html).to eq('
    def fun end
    ') end + + include_examples "XSS prevention", "" end context "when a valid language is specified" do it "highlights as that language" do result = filter('
    def fun end
    ') + expect(result.to_html).to eq('
    def fun end
    ') end + + include_examples "XSS prevention", "ruby" end context "when an invalid language is specified" do it "highlights as plaintext" do result = filter('
    This is a test
    ') + expect(result.to_html).to eq('
    This is a test
    ') end + + include_examples "XSS prevention", "gnuplot" end - context "when Rouge formatting fails" do + context "languages that should be passed through" do + %w(math mermaid plantuml).each do |lang| + context "when #{lang} is specified" do + it "highlights as plaintext but with the correct language attribute and class" do + result = filter(%{
    This is a test
    }) + + expect(result.to_html).to eq(%{
    This is a test
    }) + end + + include_examples "XSS prevention", lang + end + end + end + + context "when Rouge lexing fails" do before do - allow_any_instance_of(Rouge::Formatter).to receive(:format).and_raise(StandardError) + allow_any_instance_of(Rouge::Lexers::Ruby).to receive(:stream_tokens).and_raise(StandardError) end it "highlights as plaintext" do result = filter('
    This is a test
    ') - expect(result.to_html).to eq('
    This is a test
    ') + + expect(result.to_html).to eq('
    This is a test
    ') + end + + include_examples "XSS prevention", "ruby" + end + + context "when Rouge lexing fails after a retry" do + before do + allow_any_instance_of(Rouge::Lexers::PlainText).to receive(:stream_tokens).and_raise(StandardError) + end + + it "does not add highlighting classes" do + result = filter('
    This is a test
    ') + + expect(result.to_html).to eq('
    This is a test
    ') end + + include_examples "XSS prevention", "ruby" end end -- cgit v1.2.1