diff --git a/app/models/concerns/base_label.rb b/app/models/concerns/base_label.rb index b2faa7509e0c48cfb300a1627f2150753d637366..2153655675086bb0fa4837d61c4b7e62b437c37a 100644 --- a/app/models/concerns/base_label.rb +++ b/app/models/concerns/base_label.rb @@ -57,29 +57,12 @@ def text_color color.contrast end - def title=(value) - if value.blank? - super - else - write_attribute(:title, sanitize_value(value)) - end - end - alias_method :name=, :title= - - def description=(value) - if value.blank? - super - else - write_attribute(:description, sanitize_value(value)) - end + def name=(value) + self.title = value end private - def sanitize_value(value) - CGI.unescapeHTML(Sanitize.clean(value.to_s)) - end - def strip_whitespace_from_title self[:title] = title&.strip end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 24150aaae7610605f146988f46236c762514706a..ccef4ad57d53d2002e08f467cab27612cdfce44f 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -126,10 +126,9 @@ def reference_link_text(from = nil) self.class.reference_prefix + self.title end - def title=(value) - write_attribute(:title, sanitize_title(value)) if value.present? + def name=(value) + self.title = value end - alias_method :name=, :title= def timebox_name model_name.singular @@ -172,8 +171,4 @@ def dates_within_4_digits errors.add(:due_date, _("date must not be after 9999-12-31")) end end - - def sanitize_title(value) - CGI.unescape_html(Sanitize.clean(value.to_s)) - end end diff --git a/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb b/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb index 27c338e245d40c0d6451fbacc916641a2de1274f..0e076953410db17f21116238739ff9c3d70763b0 100644 --- a/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb +++ b/ee/spec/lib/banzai/filter/references/iteration_reference_filter_spec.rb @@ -333,14 +333,13 @@ end it_behaves_like 'a reference which does not unescape its content in data-original' do - let(:context) { { project: nil, group: group } } - let(:iteration_title) { "x<script>alert('xss');</script>" } - let(:resource) { create(:iteration, title: iteration_title, group: group) } - let(:reference) { %(#{resource.class.reference_prefix}"#{iteration_title}") } - - # This is probably bad. - let(:expected_resource_title) { "x" } + let(:context) { { project: nil, group: group } } + let(:iteration_title) { "x" } + let(:iteration_title_escaped) { "x<script>alert('xss');</script>" } + let(:resource) { create(:iteration, title: iteration_title, group: group) } + let(:reference) { %(#{resource.class.reference_prefix}"#{iteration_title_escaped}") } + let(:expected_resource_title) { iteration_title } let(:expected_href) { urls.iteration_url(resource) } let(:expected_replacement_text) { resource.display_text } end diff --git a/ee/spec/lib/ee/banzai/filter/references/label_reference_filter_spec.rb b/ee/spec/lib/ee/banzai/filter/references/label_reference_filter_spec.rb index fc1aa60fd3904cb4ebdee3b446d5366b2b1a35a9..82ef964e7bd56c850b5e09b0b2a61879877e1167 100644 --- a/ee/spec/lib/ee/banzai/filter/references/label_reference_filter_spec.rb +++ b/ee/spec/lib/ee/banzai/filter/references/label_reference_filter_spec.rb @@ -27,7 +27,17 @@ end it "doesn't unescape HTML in the label's title" do - expect(doc.at_css('.gl-label-scoped a').attr('title')).to include('xss <svg id="svgId">') + # Note that this is *flawed*: ">" from the input *text* is becoming ">" in the output *HTML*. + # No XSS is possible but we are still corrupting the user's input, albeit less badly than before. + # FIXME. + expect(doc.at_css('.gl-label-scoped a').attr('title')).to include('&lt;svg id="svgId">') + + # The below is what *should* be the case, but LabelsHelper.label_tooltip_title is (incorrectly) sanitising + # instead of safely escaping the output, and LabelsHelper.render_label_text is using html_escape_once (!!), + # which is always bad! + # + # expect(doc.at_css('.gl-label-scoped a').attr('title')).to include('xss <script>alert') + # expect(doc.at_css('.gl-label-scoped a').attr('title')).to include('&<a>lt;svg id=&quot;svgId&quot;&gt;') end end diff --git a/ee/spec/models/iteration_spec.rb b/ee/spec/models/iteration_spec.rb index b6f31284adeb5abd04c7eee15a321064e0ff9bb8..1e74e73429d5a4aa5f9e47611e63390455a6de4c 100644 --- a/ee/spec/models/iteration_spec.rb +++ b/ee/spec/models/iteration_spec.rb @@ -313,10 +313,18 @@ end describe 'title' do - subject { build(:iteration, iterations_cadence: iteration_cadence, title: '') } + subject { build(:iteration, iterations_cadence: iteration_cadence, title: input_title) } - it 'sanitizes user intput', :aggregate_failures do - expect(subject.title).to be_blank + context 'when input contains HTML entities and HTML tags' do + let(:input_title) { '<hello>' } + + it 'leaves the input title unchanged' do + # This field is not ever to be treated as HTML; it is text, never unescaped or sanitised, + # and is always escaped when inserted into HTML directly. + # If an XSS occurs in future which would lead you to wanting to "fix" this spec, please + # instead fix it at the point of display, not by corrupting user input! + expect(subject.title).to eq(input_title) + end end end end diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index 8b34c6e4a43a2459f3b1ce3c0a4f352dc3501c4d..f1f5ff961b6dc761ca1f5641fa8d9a020df1a1a8 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -313,9 +313,7 @@ end it 'responds unprocessable_entity (422) with error data' do - # Note: This assignment ensures and triggers a validation error when updating the milestone. - # Same approach used in spec/models/milestone_spec.rb . - milestone_params[:title] = '' + milestone_params[:title] = '' subject diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb index 40feb6e134877a57f0e1a9cae6e76056267532a2..dc5423528b9f6af3cdad49f2d0201e44e8ecadee 100644 --- a/spec/controllers/projects/labels_controller_spec.rb +++ b/spec/controllers/projects/labels_controller_spec.rb @@ -222,11 +222,12 @@ def toggle_subscription(label) end it 'renders label name without parsing it as HTML' do - label_1.update!(name: 'CCC<img src=x onerror=alert(document.domain)>') + name = 'CCC' + label_1.update!(name: name) post :promote, params: { namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param } - expect(flash[:notice]).to eq("CCC<img src=x onerror=alert(document.domain)> promoted to group label.") + expect(flash[:notice]).to eq("#{CGI.escapeHTML(name)} promoted to group label.") end context 'service raising InvalidRecord' do diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 7caa0c0bd57f23bf5e295f33237b336a27da66a2..7a1ac7580f112cca1974109469da3e4628fcf6ca 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -233,9 +233,7 @@ def render_index(project:, page:, search_title: '') end it 'responds unprocessable_entity (422) with error data' do - # Note: This assignment ensures and triggers a validation error when updating the milestone. - # Same approach used in spec/models/milestone_spec.rb . - milestone_params[:title] = '' + milestone_params[:title] = '' subject @@ -346,11 +344,12 @@ def render_index(project:, page:, search_title: '') end it 'renders milestone name without parsing it as HTML' do - milestone.update!(name: 'CCC<img src=x onerror=alert(document.domain)>') + name = 'CCC' + milestone.update!(name: name) post :promote, params: { namespace_id: project.namespace.id, project_id: project.id, id: milestone.iid } - expect(flash[:notice]).to eq("CCC promoted to group milestone.") + expect(flash[:notice]).to eq("#{CGI.escapeHTML(name)} promoted to group milestone.") end end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 9a1a6a9cf2f1a9e8b1958a0862e731cbc9957f87..6c6dc64a99e99026ca71b0dc55d24d6bea9ebcdb 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -147,7 +147,7 @@ let_it_be(:development) { create(:group_label, group: group, name: 'Development') } let_it_be(:stretch) { create(:label, project: project, name: 'Stretch') } let_it_be(:xss_label) do - create(:label, project: project, title: '<script>alert("xss");</script>') + create(:label, project: project, title: '') end it 'shows labels list in the dropdown' do diff --git a/spec/lib/banzai/filter/references/label_reference_filter_spec.rb b/spec/lib/banzai/filter/references/label_reference_filter_spec.rb index 55b1a1da07c22673f85b4cbfa4033f03673c79c3..f66af715a9900047f57dfb8145eed822aba57eed 100644 --- a/spec/lib/banzai/filter/references/label_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/label_reference_filter_spec.rb @@ -277,7 +277,7 @@ end context 'References with html entities' do - let!(:label) { create(:label, title: '<html>', project: project) } + let!(:label) { create(:label, title: '', project: project) } it 'links to a valid reference' do doc = reference_filter('See ~"<html>"') @@ -925,17 +925,15 @@ end it_behaves_like 'a reference which does not unescape its content in data-original' do - let(:context) { { project: project } } - let(:label_title) { "x<script>alert('xss');</script>" } - let(:resource) { create(:label, title: label_title, project: project) } - let(:reference) { %(#{resource.class.reference_prefix}"#{label_title}") } - - # This is probably bad (why are we unescaping entities in the input title like this?), - # but it is the case today, and we need to be safe in spite of this. - let(:expected_resource_title) { "x" } - - let(:expected_href) { urls.project_issues_url(project, label_name: expected_resource_title) } - let(:expected_replacement_text) { expected_resource_title } + let(:context) { { project: project } } + let(:label_title) { "x" } + let(:label_title_escaped) { "x<script>alert('xss');</script>" } + let(:resource) { create(:label, title: label_title, project: project) } + let(:reference) { %(#{resource.class.reference_prefix}"#{label_title_escaped}") } + + let(:expected_resource_title) { label_title } + let(:expected_href) { urls.project_issues_url(project, label_name: label_title) } + let(:expected_replacement_text) { label_title } end it_behaves_like 'ReferenceFilter#references_in' do diff --git a/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb index 7675eacf0cd6f1b7b593db9d2a57c389f02ce1ce..822c91d6bd1c6fd3161022c679902772928015b5 100644 --- a/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/references/milestone_reference_filter_spec.rb @@ -316,7 +316,7 @@ shared_examples 'references with HTML entities' do before do - milestone.update!(title: '<html>') + milestone.update!(title: '') end it 'links to a valid reference' do @@ -629,17 +629,15 @@ end it_behaves_like 'a reference which does not unescape its content in data-original' do - let(:context) { { project: project } } - let(:milestone_title) { "x<script>alert('xss');</script>" } - let(:resource) { create(:milestone, title: milestone_title, project: project) } - let(:reference) { %(#{resource.class.reference_prefix}"#{milestone_title}") } - - # This is probably bad (why are we unescaping entities in the input title like this?), - # but it is the case today, and we need to be safe in spite of this. - let(:expected_resource_title) { "x" } + let(:context) { { project: project } } + let(:milestone_title) { "x" } + let(:milestone_title_escaped) { "x<script>alert('xss');</script>" } + let(:resource) { create(:milestone, title: milestone_title, project: project) } + let(:reference) { %(#{resource.class.reference_prefix}"#{milestone_title_escaped}") } + let(:expected_resource_title) { milestone_title } let(:expected_href) { urls.milestone_url(resource) } - let(:expected_replacement_text) { %(#{resource.class.reference_prefix}#{expected_resource_title}) } + let(:expected_replacement_text) { %(#{resource.class.reference_prefix}#{milestone_title}) } end it_behaves_like 'ReferenceFilter#references_in' do diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index db3fd3d395fc9cd3cef3d257b69f807517de6b0d..bd885e1d2fef09a595080757c9880874c0dac540 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -103,13 +103,18 @@ end describe 'title' do - it { is_expected.to validate_presence_of(:title) } + subject { build(:milestone, project: project, title: input_title) } - it 'is invalid if title would be empty after sanitation', :aggregate_failures do - milestone = build(:milestone, project: project, title: '') + context 'when input contains HTML entities and HTML tags' do + let(:input_title) { '<hello>' } - expect(milestone).not_to be_valid - expect(milestone.errors[:title]).to include("can't be blank") + it 'leaves the input title unchanged' do + # This field is not ever to be treated as HTML; it is text, never unescaped or sanitised, + # and is always escaped when inserted into HTML directly. + # If an XSS occurs in future which would lead you to wanting to "fix" this spec, please + # instead fix it at the point of display, not by corrupting user input! + expect(subject.title).to eq(input_title) + end end end diff --git a/spec/support/shared_examples/lib/banzai/filters/reference_filter_shared_examples.rb b/spec/support/shared_examples/lib/banzai/filters/reference_filter_shared_examples.rb index d6163b19dac9ea426a100ac8185a7c1eeae31a10..0cb788bfa34db51ed4f1a005b2ba7f1e73f6dcdf 100644 --- a/spec/support/shared_examples/lib/banzai/filters/reference_filter_shared_examples.rb +++ b/spec/support/shared_examples/lib/banzai/filters/reference_filter_shared_examples.rb @@ -114,10 +114,11 @@ RSpec.shared_examples 'a reference which does not unescape its content in data-original' do it 'does not remove a layer of escaping in data-original' do # We assert the expected resource.title so calling `it_behaves_like` blocks can better describe - # the known behaviour around the resource. Some classes process their title in ways that are - # particularly relevant to XSS; the big example is Labels, which unescape HTML entities in - # their input. It's helpful to call out when this happens (and when this doesn't) so the rest - # of the behaviours are clear. + # the known behaviour around the resource. + # + # Some classes, like Label and Milestone, used to process their title in ways particularly relevant + # to XSS; they no longer do, but we want to assert they haven't regressed in this manner. + # If they did, these specs would fail in very opaque ways, as their preconditions would no longer hold. expect(resource.title).to eq(expected_resource_title) result = reference_filter("See #{reference}", context) diff --git a/spec/support/shared_examples/models/concerns/base_label_shared_examples.rb b/spec/support/shared_examples/models/concerns/base_label_shared_examples.rb index 895fff8cb710022ff1620b8f91bfd1b7081a7063..7a7e54d04eb2349911170e841421d60dc2472ec9 100644 --- a/spec/support/shared_examples/models/concerns/base_label_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/base_label_shared_examples.rb @@ -1,6 +1,22 @@ # frozen_string_literal: true RSpec.shared_examples 'BaseLabel' do |factory_name: :label| + shared_examples_for 'text input field' do |field_name| + subject { described_class.new(field_name => input) } + + context 'when input contains HTML entities and HTML tags' do + let(:input) { '<hello>' } + + it 'leaves the input unchanged' do + # This field is not ever to be treated as HTML; it is text, never unescaped or sanitised, + # and is always escaped when inserted into HTML directly. + # If an XSS occurs in future which would lead you to wanting to "fix" this spec, please + # instead fix it at the point of display, not by corrupting user input! + expect(subject.public_send(field_name)).to eq(input) + end + end + end + describe 'validation' do it 'validates color code' do is_expected.not_to allow_value('G-ITLAB').for(:color) @@ -52,31 +68,25 @@ end describe '#title' do - it 'sanitizes title' do - label = described_class.new(title: 'foo & bar?') - expect(label.title).to eq('foo & bar?') - end - it 'strips title' do label = described_class.new(title: ' label ') label.valid? expect(label.title).to eq('label') end + + it_behaves_like 'text input field', :title end describe '#description' do - it 'sanitizes description' do - label = described_class.new(description: 'foo & bar?') - expect(label.description).to eq('foo & bar?') - end - it 'accepts an empty string' do label = described_class.new(title: 'foo', description: '') label.valid? expect(label.errors[:description]).to be_empty end + + it_behaves_like 'text input field', :description end describe '.search' do diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb index 52bedcaa606bc210b1285ac695276556fd21ab3b..9556f3e8b0cdec1171f1de544a72fa73eeb57516 100644 --- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb @@ -57,15 +57,23 @@ let(:timebox) { create(timebox_type, *timebox_args, title: "foo & bar -> 2.2") } it 'normalizes the title for use as a slug' do - expect(timebox.safe_title).to eq('foo-bar-22') + expect(timebox.safe_title).to eq('bfoo-bar-22b') end end - describe "#title" do - let(:timebox) { create(timebox_type, *timebox_args, title: "foo & bar -> 2.2") } + describe '#title' do + subject { create(timebox_type, *timebox_args, title: input_title) } + + context 'when input contains HTML entities and HTML tags' do + let(:input_title) { '<hello>' } - it "sanitizes title" do - expect(timebox.title).to eq("foo & bar -> 2.2") + it 'leaves the input unchanged' do + # This field is not ever to be treated as HTML; it is text, never unescaped or sanitised, + # and is always escaped when inserted into HTML directly. + # If an XSS occurs in future which would lead you to wanting to "fix" this spec, please + # instead fix it at the point of display, not by corrupting user input! + expect(subject.title).to eq(input_title) + end end end