Commit 387a6f39 authored by Markus Koller's avatar Markus Koller Committed by GitLab Release Tools Bot

Sanitize link markup in user input for chat messages

parent d16bec0b
......@@ -6,6 +6,13 @@ module Integrations
def notify(message, opts)
# See https://gitlab.com/gitlab-org/slack-notifier/#custom-http-client
#
# TODO: By default both Markdown and HTML links are converted into Slack "mrkdwn" syntax,
# but it seems we only need to support Markdown and could disable HTML.
#
# See:
# - https://gitlab.com/gitlab-org/slack-notifier#middleware
# - https://gitlab.com/gitlab-org/gitlab/-/issues/347048
notifier = ::Slack::Messenger.new(webhook, opts.merge(http_client: HTTPClient))
notifier.ping(
message.pretext,
......
......@@ -23,7 +23,7 @@ module Integrations
def attachments
[{
title: title,
title: strip_markup(title),
title_link: alert_url,
color: attachment_color,
fields: attachment_fields
......@@ -31,7 +31,7 @@ module Integrations
end
def message
"Alert firing in #{project_name}"
"Alert firing in #{strip_markup(project_name)}"
end
private
......
......@@ -5,6 +5,10 @@ module Integrations
class BaseMessage
RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze
# Markup characters which are used for links in HTML, Markdown,
# and Slack "mrkdwn" syntax (`<http://example.com|Label>`).
UNSAFE_MARKUP_CHARACTERS = '<>[]|'
attr_reader :markdown
attr_reader :user_full_name
attr_reader :user_name
......@@ -65,12 +69,26 @@ module Integrations
string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1")
end
# Remove unsafe markup from user input, which can be used to hijack links in our own markup,
# or insert new ones.
#
# This currently removes Markdown and Slack "mrkdwn" links (keeping the link label),
# and all HTML markup (keeping the text nodes).
# We can't just escape the markup characters, because each chat app handles this differently.
#
# See:
# - https://api.slack.com/reference/surfaces/formatting#escaping
# - https://gitlab.com/gitlab-org/slack-notifier#escaping
def strip_markup(string)
string&.delete(UNSAFE_MARKUP_CHARACTERS)
end
def attachment_color
'#345'
end
def link(text, url)
"[#{text}](#{url})"
"[#{strip_markup(text)}](#{url})"
end
def pretty_duration(seconds)
......
......@@ -27,7 +27,7 @@ module Integrations
def attachments
[{
text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}",
text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{strip_markup(commit_title)}",
color: color
}]
end
......@@ -40,9 +40,9 @@ module Integrations
def message
if running?
"Starting deploy to #{environment}"
"Starting deploy to #{strip_markup(environment)}"
else
"Deploy to #{environment} #{humanized_status}"
"Deploy to #{strip_markup(environment)} #{humanized_status}"
end
end
......
......@@ -32,7 +32,7 @@ module Integrations
def activity
{
title: "Issue #{state} by #{user_combined_name}",
title: "Issue #{state} by #{strip_markup(user_combined_name)}",
subtitle: "in #{project_link}",
text: issue_link,
image: user_avatar
......@@ -42,7 +42,7 @@ module Integrations
private
def message
"[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}"
"[#{project_link}] Issue #{issue_link} #{state} by #{strip_markup(user_combined_name)}"
end
def opened_issue?
......@@ -67,7 +67,7 @@ module Integrations
end
def issue_title
"#{Issue.reference_prefix}#{issue_iid} #{title}"
"#{Issue.reference_prefix}#{issue_iid} #{strip_markup(title)}"
end
end
end
......
......@@ -29,7 +29,7 @@ module Integrations
def activity
{
title: "Merge request #{state_or_action_text} by #{user_combined_name}",
title: "Merge request #{state_or_action_text} by #{strip_markup(user_combined_name)}",
subtitle: "in #{project_link}",
text: merge_request_link,
image: user_avatar
......@@ -39,7 +39,7 @@ module Integrations
private
def format_title(title)
'*' + title.lines.first.chomp + '*'
'*' + strip_markup(title.lines.first.chomp) + '*'
end
def message
......@@ -51,7 +51,7 @@ module Integrations
end
def merge_request_message
"#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}"
"#{strip_markup(user_combined_name)} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}"
end
def merge_request_link
......@@ -59,7 +59,7 @@ module Integrations
end
def merge_request_title
"#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}"
"#{MergeRequest.reference_prefix}#{merge_request_iid} #{strip_markup(title)}"
end
def merge_request_url
......
......@@ -35,9 +35,9 @@ module Integrations
def activity
{
title: "#{user_combined_name} #{link('commented on ' + target, note_url)}",
title: "#{strip_markup(user_combined_name)} #{link('commented on ' + target, note_url)}",
subtitle: "in #{project_link}",
text: formatted_title,
text: strip_markup(formatted_title),
image: user_avatar
}
end
......@@ -45,7 +45,7 @@ module Integrations
private
def message
"#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
"#{strip_markup(user_combined_name)} #{link('commented on ' + target, note_url)} in #{project_link}: *#{strip_markup(formatted_title)}*"
end
def format_title(title)
......
......@@ -56,7 +56,7 @@ module Integrations
[{
fallback: format(message),
color: attachment_color,
author_name: user_combined_name,
author_name: strip_markup(user_combined_name),
author_icon: user_avatar,
author_link: author_url,
title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") %
......@@ -80,7 +80,7 @@ module Integrations
pipeline_link: pipeline_link,
ref_type: ref_type,
ref_link: ref_link,
user_combined_name: user_combined_name,
user_combined_name: strip_markup(user_combined_name),
humanized_status: humanized_status
},
subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link },
......@@ -154,7 +154,7 @@ module Integrations
pipeline_link: pipeline_link,
ref_type: ref_type,
ref_link: ref_link,
user_combined_name: user_combined_name,
user_combined_name: strip_markup(user_combined_name),
humanized_status: humanized_status,
duration: pretty_duration(duration)
}
......@@ -189,7 +189,7 @@ module Integrations
end
def ref_link
"[#{ref}](#{ref_url})"
link(ref, ref_url)
end
def project_url
......@@ -197,7 +197,7 @@ module Integrations
end
def project_link
"[#{project.name}](#{project_url})"
link(project.name, project_url)
end
def pipeline_failed_jobs_url
......@@ -213,7 +213,7 @@ module Integrations
end
def pipeline_link
"[##{pipeline_id}](#{pipeline_url})"
link("##{pipeline_id}", pipeline_url)
end
def job_url(job)
......@@ -221,7 +221,7 @@ module Integrations
end
def job_link(job)
"[#{job[:name]}](#{job_url(job)})"
link(job[:name], job_url(job))
end
def failed_jobs_links
......@@ -242,7 +242,7 @@ module Integrations
def stage_link(stage)
# All stages link to the pipeline page
"[#{stage}](#{pipeline_url})"
link(stage, pipeline_url)
end
def failed_stages_links
......@@ -254,7 +254,7 @@ module Integrations
end
def commit_link
"[#{commit.title}](#{commit_url})"
link(commit.title, commit_url)
end
def author_url
......
......@@ -39,7 +39,7 @@ module Integrations
def humanized_action(short: false)
action, ref_link, target_link = compose_action_details
text = [user_combined_name, action, ref_type, ref_link]
text = [strip_markup(user_combined_name), action, ref_type, ref_link]
text << target_link unless short
text.join(' ')
end
......@@ -67,7 +67,7 @@ module Integrations
url = commit[:url]
"[#{id}](#{url}): #{title} - #{author}"
"#{link(id, url)}: #{strip_markup(title)} - #{strip_markup(author)}"
end
def new_branch?
......@@ -91,15 +91,15 @@ module Integrations
end
def ref_link
"[#{ref}](#{ref_url})"
link(ref, ref_url)
end
def project_link
"[#{project_name}](#{project_url})"
link(project_name, project_url)
end
def compare_link
"[Compare changes](#{compare_url})"
link('Compare changes', compare_url)
end
def compose_action_details
......
......@@ -36,9 +36,9 @@ module Integrations
def activity
{
title: "#{user_combined_name} #{action} #{wiki_page_link}",
title: "#{strip_markup(user_combined_name)} #{action} #{wiki_page_link}",
subtitle: "in #{project_link}",
text: title,
text: strip_markup(title),
image: user_avatar
}
end
......@@ -46,7 +46,7 @@ module Integrations
private
def message
"#{user_combined_name} #{action} #{wiki_page_link} (#{diff_link}) in #{project_link}: *#{title}*"
"#{strip_markup(user_combined_name)} #{action} #{wiki_page_link} (#{diff_link}) in #{project_link}: *#{strip_markup(title)}*"
end
def description_message
......
......@@ -16,6 +16,8 @@ RSpec.describe Integrations::ChatMessage::AlertMessage do
}.merge(Gitlab::DataBuilder::Alert.build(alert))
end
it_behaves_like Integrations::ChatMessage
describe '#message' do
it 'returns the correct message' do
expect(subject.message).to eq("Alert firing in #{args[:project_name]}")
......
......@@ -31,4 +31,22 @@ RSpec.describe Integrations::ChatMessage::BaseMessage do
it { is_expected.to eq('Check this out https://gitlab-domain.com/uploads/Screenshot1.png. And this https://gitlab-domain.com/uploads/Screenshot2.png') }
end
end
describe '#strip_markup' do
using RSpec::Parameterized::TableSyntax
where(:input, :output) do
nil | nil
'' | ''
'[label](url)' | 'label(url)'
'<url|label>' | 'urllabel'
'<a href="url">label</a>' | 'a href="url"label/a'
end
with_them do
it 'returns the expected output' do
expect(base_message.send(:strip_markup, input)).to eq(output)
end
end
end
end
......@@ -3,83 +3,79 @@
require 'spec_helper'
RSpec.describe Integrations::ChatMessage::DeploymentMessage do
describe '#pretext' do
it 'returns a message with the data returned by the deployment data builder' do
environment = create(:environment, name: "myenvironment")
project = create(:project, :repository)
commit = project.commit('HEAD')
deployment = create(:deployment, status: :success, environment: environment, project: project, sha: commit.sha)
data = Gitlab::DataBuilder::Deployment.build(deployment, Time.current)
subject { described_class.new(args) }
let_it_be(:user) { create(:user, name: 'John Smith', username: 'smith') }
let_it_be(:namespace) { create(:namespace, name: 'myspace') }
let_it_be(:project) { create(:project, :repository, namespace: namespace, name: 'myproject') }
let_it_be(:commit) { project.commit('HEAD') }
let_it_be(:ci_build) { create(:ci_build, project: project) }
let_it_be(:environment) { create(:environment, name: 'myenvironment', project: project) }
let_it_be(:deployment) { create(:deployment, status: :success, deployable: ci_build, environment: environment, project: project, user: user, sha: commit.sha) }
let(:args) do
Gitlab::DataBuilder::Deployment.build(deployment, Time.current)
end
message = described_class.new(data)
it_behaves_like Integrations::ChatMessage
expect(message.pretext).to eq("Deploy to myenvironment succeeded")
describe '#pretext' do
it 'returns a message with the data returned by the deployment data builder' do
expect(subject.pretext).to eq("Deploy to myenvironment succeeded")
end
it 'returns a message for a successful deployment' do
data = {
args.merge!(
status: 'success',
environment: 'production'
}
)
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to production succeeded')
expect(subject.pretext).to eq('Deploy to production succeeded')
end
it 'returns a message for a failed deployment' do
data = {
args.merge!(
status: 'failed',
environment: 'production'
}
)
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to production failed')
expect(subject.pretext).to eq('Deploy to production failed')
end
it 'returns a message for a canceled deployment' do
data = {
args.merge!(
status: 'canceled',
environment: 'production'
}
message = described_class.new(data)
)
expect(message.pretext).to eq('Deploy to production canceled')
expect(subject.pretext).to eq('Deploy to production canceled')
end
it 'returns a message for a deployment to another environment' do
data = {
args.merge!(
status: 'success',
environment: 'staging'
}
message = described_class.new(data)
)
expect(message.pretext).to eq('Deploy to staging succeeded')
expect(subject.pretext).to eq('Deploy to staging succeeded')
end
it 'returns a message for a deployment with any other status' do
data = {
args.merge!(
status: 'unknown',
environment: 'staging'
}
)
message = described_class.new(data)
expect(message.pretext).to eq('Deploy to staging unknown')
expect(subject.pretext).to eq('Deploy to staging unknown')
end
it 'returns a message for a running deployment' do
data = {
status: 'running',
environment: 'production'
}
message = described_class.new(data)
args.merge!(
status: 'running',
environment: 'production'
)
expect(message.pretext).to eq('Starting deploy to production')
expect(subject.pretext).to eq('Starting deploy to production')
end
end
......@@ -108,21 +104,11 @@ RSpec.describe Integrations::ChatMessage::DeploymentMessage do
end
it 'returns attachments with the data returned by the deployment data builder' do
user = create(:user, name: "John Smith", username: "smith")
namespace = create(:namespace, name: "myspace")
project = create(:project, :repository, namespace: namespace, name: "myproject")
commit = project.commit('HEAD')
environment = create(:environment, name: "myenvironment", project: project)
ci_build = create(:ci_build, project: project)
deployment = create(:deployment, :success, deployable: ci_build, environment: environment, project: project, user: user, sha: commit.sha)
job_url = Gitlab::Routing.url_helpers.project_job_url(project, ci_build)
commit_url = Gitlab::UrlBuilder.build(deployment.commit)
user_url = Gitlab::Routing.url_helpers.user_url(user)
data = Gitlab::DataBuilder::Deployment.build(deployment, Time.current)
message = described_class.new(data)
expect(message.attachments).to eq([{
expect(subject.attachments).to eq([{
text: "[myspace/myproject](#{project.web_url}) with job [##{ci_build.id}](#{job_url}) by [John Smith (smith)](#{user_url})\n[#{deployment.short_sha}](#{commit_url}): #{commit.title}",
color: "good"
}])
......
......@@ -28,6 +28,8 @@ RSpec.describe Integrations::ChatMessage::IssueMessage do
}
end
it_behaves_like Integrations::ChatMessage
context 'without markdown' do
let(:color) { '#C95823' }
......
......@@ -29,6 +29,8 @@ RSpec.describe Integrations::ChatMessage::MergeMessage do
}
end
it_behaves_like Integrations::ChatMessage
context 'without markdown' do
let(:color) { '#345' }
......
......@@ -19,6 +19,10 @@ RSpec.describe Integrations::ChatMessage::NoteMessage do
name: 'project_name',
url: 'http://somewhere.com'
},
commit: {
id: '5f163b2b95e6f53cbd428f5f0b103702a52b9a23',
message: "Added a commit message\ndetails\n123\n"
},
object_attributes: {
id: 10,
note: 'comment on a commit',
......@@ -28,16 +32,9 @@ RSpec.describe Integrations::ChatMessage::NoteMessage do
}
end
context 'commit notes' do
before do
args[:object_attributes][:note] = 'comment on a commit'
args[:object_attributes][:noteable_type] = 'Commit'
args[:commit] = {
id: '5f163b2b95e6f53cbd428f5f0b103702a52b9a23',
message: "Added a commit message\ndetails\n123\n"
}
end
it_behaves_like Integrations::ChatMessage
context 'commit notes' do
context 'without markdown' do
it 'returns a message regarding notes on commits' do
expect(subject.pretext).to eq("Test User (test.user) <http://url.com|commented on " \
......
......@@ -40,6 +40,8 @@ RSpec.describe Integrations::ChatMessage::PipelineMessage do
let(:has_yaml_errors) { false }
it_behaves_like Integrations::ChatMessage
before do
test_commit = double("A test commit", committer: args[:user], title: "A test commit message")
test_project = double("A test project", commit_by: test_commit, name: args[:project][:name], web_url: args[:project][:web_url])
......
......@@ -19,6 +19,8 @@ RSpec.describe Integrations::ChatMessage::PushMessage do
let(:color) { '#345' }
it_behaves_like Integrations::ChatMessage
context 'push' do
before do
args[:commits] = [
......
......@@ -33,6 +33,8 @@ RSpec.describe Integrations::ChatMessage::WikiPageMessage do
}
end
it_behaves_like Integrations::ChatMessage
context 'without markdown' do
describe '#pretext' do
context 'when :action == "create"' do
......
# frozen_string_literal: true
RSpec.shared_examples Integrations::ChatMessage do
context 'when input contains link markup' do
let(:evil_input) { '[Markdown](http://evil.com) <a href="http://evil.com">HTML</a> <http://evil.com|Slack>' }
# Attributes returned from #activity and #attributes which should be sanitized.
let(:sanitized_attributes) do
%i[title subtitle text fallback author_name]
end
# Attributes passed to #initialize which can contain user input.
before do
args.deep_merge!(
project_name: evil_input,
user_name: evil_input,
user_full_name: evil_input,
commit_title: evil_input,
environment: evil_input,
project: {
name: evil_input
},
user: {
name: evil_input,
username: evil_input
},
object_attributes: {
title: evil_input
}
)
end
# NOTE: The `include` matcher is used here so the RSpec error messages will tell us
# which method or attribute is failing, even though it makes the spec a bit less readable.
it 'strips all link markup characters', :aggregate_failures do
expect(subject).not_to have_attributes(
pretext: include(evil_input),
summary: include(evil_input)
)
begin
sanitized_attributes.each do |attribute|
expect(subject.activity).not_to include(attribute => include(evil_input))
end
rescue NotImplementedError
end
begin
sanitized_attributes.each do |attribute|
expect(subject.attachments).not_to include(include(attribute => include(evil_input)))
end
rescue NotImplementedError
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment