diff --git a/app/helpers/better_together/content/blocks_helper.rb b/app/helpers/better_together/content/blocks_helper.rb index 178030fd4..789e5fe64 100644 --- a/app/helpers/better_together/content/blocks_helper.rb +++ b/app/helpers/better_together/content/blocks_helper.rb @@ -1,9 +1,20 @@ # frozen_string_literal: true +require 'css_parser' + module BetterTogether module Content # Helpers for Content Blocks module BlocksHelper + ALLOWED_CSS_PROPERTIES = %w[ + align-items aspect-ratio background background-color border border-color border-radius + border-style border-width box-shadow color content display flex flex-direction font-family + font-size font-weight height justify-content left line-height margin margin-bottom margin-left + margin-right margin-top max-height max-width min-height min-width object-fit overflow + overflow-y padding padding-bottom padding-left padding-right padding-top position text-align + text-decoration text-shadow width z-index + ].freeze + # Returns an array of acceptable image file types def acceptable_image_file_types BetterTogether::Attachments::Images::VALID_IMAGE_CONTENT_TYPES @@ -14,6 +25,42 @@ def temp_id_for(model, temp_id: SecureRandom.uuid) model.persisted? ? model.id : temp_id end + # Sanitize css content by only allowing whitelisted properties + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize + def sanitize_css(content) + return if content.blank? + + parser = CssParser::Parser.new + parser.add_block!(content) + safe_css = '' + + parser.each_selector do |selector, declarations, _specificity, media_types| + safe_decls = declarations.split(';').filter_map do |decl| + raw_property, value = decl.split(':', 2) + next unless raw_property && value + + property = raw_property.strip + normalized = property.downcase + value = value.strip + next unless ALLOWED_CSS_PROPERTIES.include?(normalized) || property.start_with?('--') + + "#{property}: #{value}" + end + next if safe_decls.empty? + + if media_types&.any? + safe_css << "@media #{media_types.join(', ')} { #{selector} { #{safe_decls.join('; ')} } }\n" + else + safe_css << "#{selector} { #{safe_decls.join('; ')} }\n" + end + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize + + safe_css.presence + rescue CssParser::ParserError + nil + end + # Sanitize HTML content for safe rendering in custom blocks def sanitize_block_html(html) allowed_tags = %w[p br strong em b i ul ol li a span h1 h2 h3 h4 h5 h6 img figure figcaption blockquote pre diff --git a/app/views/better_together/content/blocks/_css.html.erb b/app/views/better_together/content/blocks/_css.html.erb index 5658cd7f5..7afce11d4 100644 --- a/app/views/better_together/content/blocks/_css.html.erb +++ b/app/views/better_together/content/blocks/_css.html.erb @@ -1,4 +1,7 @@ <% if css.content.present? %> - <%= content_tag :style, sanitize_block_css(css.content), type: 'text/css', id: dom_id(css) %> + <% sanitized = sanitize_css(css.content) %> + <% if sanitized.present? %> + <%= content_tag :style, sanitized.html_safe, type: 'text/css', id: dom_id(css) %> + <% end %> <% end %> diff --git a/spec/views/better_together/content/blocks/css.html.erb_spec.rb b/spec/views/better_together/content/blocks/css.html.erb_spec.rb new file mode 100644 index 000000000..48997e861 --- /dev/null +++ b/spec/views/better_together/content/blocks/css.html.erb_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'better_together/content/blocks/css', type: :view do + it 'renders whitelisted properties' do + css = BetterTogether::Content::Css.new(content: 'h1 { color: red; position: relative; z-index: 1; }') + + render partial: 'better_together/content/blocks/css', locals: { css: css } + + expect(rendered).to include('color: red') + expect(rendered).to include('position: relative') + expect(rendered).to include('z-index: 1') + expect(rendered).not_to include('') + end + + it 'strips disallowed properties' do + css = BetterTogether::Content::Css.new(content: 'h1 { color: red; float: left; }') + + render partial: 'better_together/content/blocks/css', locals: { css: css } + + expect(rendered).to include('color: red') + expect(rendered).not_to include('float: left') + end + + it 'rejects block when all rules disallowed' do + css = BetterTogether::Content::Css.new(content: 'h1 { float: left; }') + + render partial: 'better_together/content/blocks/css', locals: { css: css } + + expect(rendered.strip).to be_empty + end + + it 'preserves media queries while filtering properties' do + css = BetterTogether::Content::Css.new( + content: '@media only screen and (min-width: 768px) { h1 { font-size: 3em; float: left; } }' + ) + + render partial: 'better_together/content/blocks/css', locals: { css: css } + + expect(rendered).to include('@media only screen and (min-width: 768px)') + expect(rendered).to include('font-size: 3em') + expect(rendered).not_to include('float: left') + end + + it 'allows custom properties' do + css = BetterTogether::Content::Css.new( + content: '.navbar { --bs-navbar-toggler-padding-x: 0.25rem; }' + ) + + render partial: 'better_together/content/blocks/css', locals: { css: css } + + expect(rendered).to include('--bs-navbar-toggler-padding-x: 0.25rem') + end +end