DEV: Sanitize HTML admin inputs (#14681)
* DEV: Sanitize HTML admin inputs
This PR adds on-save HTML sanitization for:
Client site settings
translation overrides
badges descriptions
user fields descriptions
I used Rails's SafeListSanitizer, which [accepts the following HTML tags and attributes](018cf54073/lib/rails/html/sanitizer.rb (L108)
)
* Make sure that the sanitization logic doesn't corrupt settings with special characters
This commit is contained in:
parent
184ccf4490
commit
df3eb93973
|
@ -5,6 +5,7 @@ class Badge < ActiveRecord::Base
|
||||||
self.ignored_columns = %w{image}
|
self.ignored_columns = %w{image}
|
||||||
|
|
||||||
include GlobalPath
|
include GlobalPath
|
||||||
|
include HasSanitizableFields
|
||||||
|
|
||||||
# NOTE: These badge ids are not in order! They are grouped logically.
|
# NOTE: These badge ids are not in order! They are grouped logically.
|
||||||
# When picking an id, *search* for it.
|
# When picking an id, *search* for it.
|
||||||
|
@ -116,6 +117,7 @@ class Badge < ActiveRecord::Base
|
||||||
scope :enabled, -> { where(enabled: true) }
|
scope :enabled, -> { where(enabled: true) }
|
||||||
|
|
||||||
before_create :ensure_not_system
|
before_create :ensure_not_system
|
||||||
|
before_save :sanitize_description
|
||||||
|
|
||||||
after_commit do
|
after_commit do
|
||||||
SvgSprite.expire_cache
|
SvgSprite.expire_cache
|
||||||
|
@ -314,6 +316,12 @@ class Badge < ActiveRecord::Base
|
||||||
def ensure_not_system
|
def ensure_not_system
|
||||||
self.id = [Badge.maximum(:id) + 1, 100].max unless id
|
self.id = [Badge.maximum(:id) + 1, 100].max unless id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitize_description
|
||||||
|
if description_changed?
|
||||||
|
self.description = sanitize_field(self.description)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module HasSanitizableFields
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def sanitize_field(field, additional_attributes: [])
|
||||||
|
if field
|
||||||
|
sanitizer = Rails::Html::SafeListSanitizer.new
|
||||||
|
allowed_attributes = Rails::Html::SafeListSanitizer.allowed_attributes
|
||||||
|
|
||||||
|
if additional_attributes.present?
|
||||||
|
allowed_attributes = allowed_attributes.merge(additional_attributes)
|
||||||
|
end
|
||||||
|
|
||||||
|
field = CGI.unescape_html(sanitizer.sanitize(field, attributes: allowed_attributes))
|
||||||
|
# Just replace the characters that our translations use for interpolation.
|
||||||
|
# Calling CGI.unescape removes characters like '+', which will corrupt the original value.
|
||||||
|
field = field.gsub('%7B', '{').gsub('%7D', '}')
|
||||||
|
end
|
||||||
|
|
||||||
|
field
|
||||||
|
end
|
||||||
|
end
|
|
@ -39,6 +39,7 @@ class TranslationOverride < ActiveRecord::Base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
include HasSanitizableFields
|
||||||
include ActiveSupport::Deprecation::DeprecatedConstantAccessor
|
include ActiveSupport::Deprecation::DeprecatedConstantAccessor
|
||||||
deprecate_constant 'CUSTOM_INTERPOLATION_KEYS_WHITELIST', 'TranslationOverride::ALLOWED_CUSTOM_INTERPOLATION_KEYS'
|
deprecate_constant 'CUSTOM_INTERPOLATION_KEYS_WHITELIST', 'TranslationOverride::ALLOWED_CUSTOM_INTERPOLATION_KEYS'
|
||||||
|
|
||||||
|
@ -50,13 +51,15 @@ class TranslationOverride < ActiveRecord::Base
|
||||||
def self.upsert!(locale, key, value)
|
def self.upsert!(locale, key, value)
|
||||||
params = { locale: locale, translation_key: key }
|
params = { locale: locale, translation_key: key }
|
||||||
|
|
||||||
data = { value: value }
|
translation_override = find_or_initialize_by(params)
|
||||||
|
sanitized_value = translation_override.sanitize_field(value, additional_attributes: ['data-auto-route'])
|
||||||
|
|
||||||
|
data = { value: sanitized_value }
|
||||||
if key.end_with?('_MF')
|
if key.end_with?('_MF')
|
||||||
_, filename = JsLocaleHelper.find_message_format_locale([locale], fallback_to_english: false)
|
_, filename = JsLocaleHelper.find_message_format_locale([locale], fallback_to_english: false)
|
||||||
data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, value)
|
data[:compiled_js] = JsLocaleHelper.compile_message_format(filename, locale, sanitized_value)
|
||||||
end
|
end
|
||||||
|
|
||||||
translation_override = find_or_initialize_by(params)
|
|
||||||
params.merge!(data) if translation_override.new_record?
|
params.merge!(data) if translation_override.new_record?
|
||||||
i18n_changed(locale, [key]) if translation_override.update(data)
|
i18n_changed(locale, [key]) if translation_override.update(data)
|
||||||
translation_override
|
translation_override
|
||||||
|
@ -125,7 +128,6 @@ class TranslationOverride < ActiveRecord::Base
|
||||||
if original_text
|
if original_text
|
||||||
original_interpolation_keys = I18nInterpolationKeysFinder.find(original_text)
|
original_interpolation_keys = I18nInterpolationKeysFinder.find(original_text)
|
||||||
new_interpolation_keys = I18nInterpolationKeysFinder.find(value)
|
new_interpolation_keys = I18nInterpolationKeysFinder.find(value)
|
||||||
|
|
||||||
custom_interpolation_keys = []
|
custom_interpolation_keys = []
|
||||||
|
|
||||||
ALLOWED_CUSTOM_INTERPOLATION_KEYS.select do |keys, value|
|
ALLOWED_CUSTOM_INTERPOLATION_KEYS.select do |keys, value|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
class UserField < ActiveRecord::Base
|
class UserField < ActiveRecord::Base
|
||||||
|
|
||||||
include AnonCacheInvalidator
|
include AnonCacheInvalidator
|
||||||
|
include HasSanitizableFields
|
||||||
|
|
||||||
validates_presence_of :description, :field_type
|
validates_presence_of :description, :field_type
|
||||||
validates_presence_of :name, unless: -> { field_type == "confirm" }
|
validates_presence_of :name, unless: -> { field_type == "confirm" }
|
||||||
|
@ -10,6 +11,7 @@ class UserField < ActiveRecord::Base
|
||||||
has_one :directory_column, dependent: :destroy
|
has_one :directory_column, dependent: :destroy
|
||||||
accepts_nested_attributes_for :user_field_options
|
accepts_nested_attributes_for :user_field_options
|
||||||
|
|
||||||
|
before_save :sanitize_description
|
||||||
after_save :queue_index_search
|
after_save :queue_index_search
|
||||||
|
|
||||||
def self.max_length
|
def self.max_length
|
||||||
|
@ -19,6 +21,14 @@ class UserField < ActiveRecord::Base
|
||||||
def queue_index_search
|
def queue_index_search
|
||||||
SearchIndexer.queue_users_reindex(UserCustomField.where(name: "user_field_#{self.id}").pluck(:user_id))
|
SearchIndexer.queue_users_reindex(UserCustomField.where(name: "user_field_#{self.id}").pluck(:user_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def sanitize_description
|
||||||
|
if description_changed?
|
||||||
|
self.description = sanitize_field(self.description)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# == Schema Information
|
# == Schema Information
|
||||||
|
|
|
@ -4555,11 +4555,11 @@ en:
|
||||||
|
|
||||||
Prefixing the property names is highly recommended to avoid conflicts with plugins and/or core.
|
Prefixing the property names is highly recommended to avoid conflicts with plugins and/or core.
|
||||||
head_tag:
|
head_tag:
|
||||||
text: "</head>"
|
text: "Head"
|
||||||
title: "HTML that will be inserted before the </head> tag"
|
title: "HTML that will be inserted before the head tag"
|
||||||
body_tag:
|
body_tag:
|
||||||
text: "</body>"
|
text: "Body"
|
||||||
title: "HTML that will be inserted before the </body> tag"
|
title: "HTML that will be inserted before the body tag"
|
||||||
yaml:
|
yaml:
|
||||||
text: "YAML"
|
text: "YAML"
|
||||||
title: "Define theme settings in YAML format"
|
title: "Define theme settings in YAML format"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
module SiteSettingExtension
|
module SiteSettingExtension
|
||||||
include SiteSettings::DeprecatedSettings
|
include SiteSettings::DeprecatedSettings
|
||||||
|
include HasSanitizableFields
|
||||||
|
|
||||||
# support default_locale being set via global settings
|
# support default_locale being set via global settings
|
||||||
# this also adds support for testing the extension and global settings
|
# this also adds support for testing the extension and global settings
|
||||||
|
@ -362,8 +363,12 @@ module SiteSettingExtension
|
||||||
def add_override!(name, val)
|
def add_override!(name, val)
|
||||||
old_val = current[name]
|
old_val = current[name]
|
||||||
val, type = type_supervisor.to_db_value(name, val)
|
val, type = type_supervisor.to_db_value(name, val)
|
||||||
provider.save(name, val, type)
|
|
||||||
current[name] = type_supervisor.to_rb_value(name, val)
|
sanitize_override = val.is_a?(String) && client_settings.include?(name)
|
||||||
|
|
||||||
|
sanitized_val = sanitize_override ? sanitize_field(val) : val
|
||||||
|
provider.save(name, sanitized_val, type)
|
||||||
|
current[name] = type_supervisor.to_rb_value(name, sanitized_val)
|
||||||
clear_uploads_cache(name)
|
clear_uploads_cache(name)
|
||||||
notify_clients!(name) if client_settings.include? name
|
notify_clients!(name) if client_settings.include? name
|
||||||
clear_cache!
|
clear_cache!
|
||||||
|
|
|
@ -51,6 +51,15 @@ describe Badge do
|
||||||
expect(b.grant_count).to eq(1)
|
expect(b.grant_count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sanitizes the description' do
|
||||||
|
xss = "<b onmouseover=alert('Wufff!')>click me!</b><script>alert('TEST');</script>"
|
||||||
|
badge = Fabricate(:badge)
|
||||||
|
|
||||||
|
badge.update!(description: xss)
|
||||||
|
|
||||||
|
expect(badge.description).to eq("<b>click me!</b>alert('TEST');")
|
||||||
|
end
|
||||||
|
|
||||||
describe '#manually_grantable?' do
|
describe '#manually_grantable?' do
|
||||||
fab!(:badge) { Fabricate(:badge, name: 'Test Badge') }
|
fab!(:badge) { Fabricate(:badge, name: 'Test Badge') }
|
||||||
subject { badge.manually_grantable? }
|
subject { badge.manually_grantable? }
|
||||||
|
|
|
@ -204,4 +204,22 @@ describe SiteSetting do
|
||||||
expect(SiteSetting.blocked_attachment_filenames_regex).to eq(/foo|bar/)
|
expect(SiteSetting.blocked_attachment_filenames_regex).to eq(/foo|bar/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sanitizes the client settings when they are overridden' do
|
||||||
|
xss = "<b onmouseover=alert('Wufff!')>click me!</b><script>alert('TEST');</script>"
|
||||||
|
|
||||||
|
SiteSetting.global_notice = xss
|
||||||
|
|
||||||
|
expect(SiteSetting.global_notice).to eq("<b>click me!</b>alert('TEST');")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't corrupt site settings with special characters" do
|
||||||
|
value = 'OX5y3Oljb+Qt9Bu809vsBQ==<>!%{}*&!@#$%..._-A'
|
||||||
|
settings = new_settings(SiteSettings::LocalProcessProvider.new)
|
||||||
|
settings.setting(:test_setting, '', client: true)
|
||||||
|
|
||||||
|
settings.test_setting = value
|
||||||
|
|
||||||
|
expect(settings.test_setting).to eq(value)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -115,6 +115,16 @@ describe TranslationOverride do
|
||||||
expect(ovr.value).to eq('some value')
|
expect(ovr.value).to eq('some value')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sanitizes values before upsert' do
|
||||||
|
xss = "<a href='%{url}' data-auto-route='true'>setup wizard</a> ✨<script>alert('TEST');</script>"
|
||||||
|
|
||||||
|
TranslationOverride.upsert!('en', 'js.wizard_required', xss)
|
||||||
|
|
||||||
|
ovr = TranslationOverride.where(locale: 'en', translation_key: 'js.wizard_required').first
|
||||||
|
expect(ovr).to be_present
|
||||||
|
expect(ovr.value).to eq("<a href=\"%{url}\" data-auto-route=\"true\">setup wizard</a> ✨alert('TEST');")
|
||||||
|
end
|
||||||
|
|
||||||
it "stores js for a message format key" do
|
it "stores js for a message format key" do
|
||||||
TranslationOverride.upsert!('ru', 'some.key_MF', '{NUM_RESULTS, plural, one {1 result} other {many} }')
|
TranslationOverride.upsert!('ru', 'some.key_MF', '{NUM_RESULTS, plural, one {1 result} other {many} }')
|
||||||
|
|
||||||
|
|
|
@ -12,4 +12,13 @@ describe UserField do
|
||||||
subject { described_class.new(field_type: 'dropdown') }
|
subject { described_class.new(field_type: 'dropdown') }
|
||||||
it { is_expected.to validate_presence_of :name }
|
it { is_expected.to validate_presence_of :name }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sanitizes the description' do
|
||||||
|
xss = "<b onmouseover=alert('Wufff!')>click me!</b><script>alert('TEST');</script>"
|
||||||
|
user_field = Fabricate(:user_field)
|
||||||
|
|
||||||
|
user_field.update!(description: xss)
|
||||||
|
|
||||||
|
expect(user_field.description).to eq("<b>click me!</b>alert('TEST');")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue