Module: SMEditor::Rails::Sanitizer

Defined in:
lib/smeditor/rails/sanitizer.rb

Overview

Wraps Rails' HTML sanitizer with a SMEditor-shaped allow-list.

Stored editor HTML is treated as untrusted user input, even when the JavaScript serializer produced it. Public display should pass through this adapter first.

Constant Summary collapse

ALLOWED_ATTRS =

Attributes kept on surviving tags. style is included because SMEditor serializes color, background, highlight, text stroke, font size, font family and alignment as constrained inline CSS.

%w[
  href src alt title class style target rel
  width height data-align data-mention-id data-mention-label
  colspan rowspan
].freeze
COLOR_PATTERN =
/\A(?:#[0-9a-fA-F]{3,8}|rgba?\([^;<>"']+\)|hsla?\([^;<>"']+\)|[a-zA-Z]+)\z/
FONT_FAMILY_PATTERN =
/\A[a-zA-Z0-9 ,"'-]{1,200}\z/
LENGTH_PATTERN =
/\A(\d+(?:\.\d+)?)(px|pt|em|rem|%)?\z/
LINE_HEIGHT_PATTERN =
/\A(\d+(?:\.\d+)?)(px|em|rem|%)?\z/
TEXT_STROKE_WIDTH_PATTERN =
/\A(\d+(?:\.\d+)?)px\z/i
SAFE_TARGETS =
%w[_blank _self _parent _top].freeze
RASTER_DATA_URL_PATTERN =
/\Adata:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+\/=\s]+\z/i

Class Method Summary collapse

Class Method Details

.harden_blank_targets(html) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/smeditor/rails/sanitizer.rb', line 86

def harden_blank_targets(html)
  html.gsub(/<a\b([^>]*)>/mi) do
    attrs = Regexp.last_match(1).to_s
    if attrs.match?(/\starget=(['"])_blank\1/i)
      if attrs.match?(/\srel=/i)
        %(<a#{attrs}>)
      else
        %(<a#{attrs} rel="noopener noreferrer">)
      end
    else
      %(<a#{attrs}>)
    end
  end
end

.safe_url?(attribute, value) ⇒ Boolean

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/smeditor/rails/sanitizer.rb', line 179

def safe_url?(attribute, value)
  url = value.to_s.strip
  if url.empty? || url.match?(/[\u0000-\u001f\u007f\s<>"']/)
    false
  elsif attribute == "src" && url.match?(/\Adata:/i)
    url.match?(RASTER_DATA_URL_PATTERN)
  elsif url.start_with?("#")
    attribute == "href"
  elsif url.start_with?("//")
    false
  elsif url.start_with?("/", "?")
    true
  elsif !url.include?(":")
    url.match?(/\A[.\w~-][.\w~!$&'()*+,;=:@\/-]*(?:\?[^#]*)?(?:#.*)?\z/)
  else
    protocol = url.split(":", 2).first.downcase
    if attribute == "href"
      %w[http https mailto tel].include?(protocol)
    else
      %w[http https].include?(protocol)
    end
  end
end

.sanitize(html, allowed_tags: nil) ⇒ Object

Produces a sanitized HTML String (not html_safe — the caller decides where it is safe to mark it so).



34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/smeditor/rails/sanitizer.rb', line 34

def sanitize(html, allowed_tags: nil)
  tags = allowed_tags || SMEditor.config.allowed_tags
  sanitized = sanitizer_class.new.sanitize(
    html.to_s,
    tags: tags,
    attributes: ALLOWED_ATTRS,
  ).to_s
  harden_blank_targets(
    sanitize_url_attributes(
      sanitize_inline_styles(sanitized),
    ),
  )
end

.sanitize_color(value) ⇒ Object



136
137
138
139
# File 'lib/smeditor/rails/sanitizer.rb', line 136

def sanitize_color(value)
  color = value.to_s.strip
  color.match?(COLOR_PATTERN) && !color.match?(/[;:"'<>\\]/) ? color : nil
end

.sanitize_font_family(value) ⇒ Object



141
142
143
144
# File 'lib/smeditor/rails/sanitizer.rb', line 141

def sanitize_font_family(value)
  family = value.to_s.strip
  family.match?(FONT_FAMILY_PATTERN) ? family : nil
end

.sanitize_font_size(value) ⇒ Object



146
147
148
149
150
151
152
# File 'lib/smeditor/rails/sanitizer.rb', line 146

def sanitize_font_size(value)
  size = value.to_s.strip
  match = size.match(LENGTH_PATTERN)
  if match && match[1].to_f.positive? && match[1].to_f <= 400
    "#{match[1]}#{match[2] || 'px'}"
  end
end

.sanitize_inline_styles(html) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/smeditor/rails/sanitizer.rb', line 58

def sanitize_inline_styles(html)
  html.gsub(/\sstyle=(['"])(.*?)\1/mi) do
    quote = Regexp.last_match(1)
    style = CGI.unescapeHTML(Regexp.last_match(2).to_s)
    declarations = style.split(";").filter_map do |declaration|
      sanitize_style_declaration(declaration)
    end
    if declarations.empty?
      ""
    else
      %( style=#{quote}#{CGI.escapeHTML(declarations.join("; "))}#{quote})
    end
  end
end

.sanitize_line_height(value) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/smeditor/rails/sanitizer.rb', line 154

def sanitize_line_height(value)
  height = value.to_s.strip
  match = height.match(LINE_HEIGHT_PATTERN)
  if match && match[1].to_f.positive? && match[1].to_f <= 10
    "#{match[1]}#{match[2]}"
  end
end

.sanitize_style_declaration(declaration) ⇒ Object



101
102
103
104
105
106
107
108
# File 'lib/smeditor/rails/sanitizer.rb', line 101

def sanitize_style_declaration(declaration)
  match = declaration.match(/\A\s*([a-z-]+)\s*:\s*(.+?)\s*\z/i)
  if match
    name = match[1].downcase
    value = sanitize_style_value(name, match[2])
    value ? "#{name}: #{value}" : nil
  end
end

.sanitize_style_value(name, value) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/smeditor/rails/sanitizer.rb', line 110

def sanitize_style_value(name, value)
  case name
  when "text-align"
    sanitize_text_align(value)
  when "color", "background-color", "background"
    sanitize_color(value)
  when "font-family"
    sanitize_font_family(value)
  when "font-size"
    sanitize_font_size(value)
  when "line-height"
    sanitize_line_height(value)
  when "-webkit-text-stroke", "text-stroke"
    sanitize_text_stroke(value)
  when "-webkit-text-stroke-color", "text-stroke-color"
    sanitize_color(value)
  when "-webkit-text-stroke-width", "text-stroke-width"
    sanitize_text_stroke_width(value)
  end
end

.sanitize_text_align(value) ⇒ Object



131
132
133
134
# File 'lib/smeditor/rails/sanitizer.rb', line 131

def sanitize_text_align(value)
  align = value.to_s.strip.downcase
  %w[left center right justify].include?(align) ? align : nil
end

.sanitize_text_stroke(value) ⇒ Object



162
163
164
165
166
167
168
169
# File 'lib/smeditor/rails/sanitizer.rb', line 162

def sanitize_text_stroke(value)
  match = value.to_s.strip.match(/\A([^\s]+)\s+(.+)\z/)
  if match
    width = sanitize_text_stroke_width(match[1])
    color = sanitize_color(match[2])
    width && color ? "#{width} #{color}" : nil
  end
end

.sanitize_text_stroke_width(value) ⇒ Object



171
172
173
174
175
176
177
# File 'lib/smeditor/rails/sanitizer.rb', line 171

def sanitize_text_stroke_width(value)
  width = value.to_s.strip
  match = width.match(TEXT_STROKE_WIDTH_PATTERN)
  if match && match[1].to_f >= 0 && match[1].to_f <= 8
    "#{match[1]}px"
  end
end

.sanitize_url_attributes(html) ⇒ Object



73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/smeditor/rails/sanitizer.rb', line 73

def sanitize_url_attributes(html)
  html.gsub(/\s(href|src)=(['"])(.*?)\2/mi) do
    name = Regexp.last_match(1).downcase
    quote = Regexp.last_match(2)
    value = CGI.unescapeHTML(Regexp.last_match(3).to_s)
    safe_url?(name, value) ? %( #{name}=#{quote}#{CGI.escapeHTML(value.strip)}#{quote}) : ""
  end.gsub(/\starget=(['"])(.*?)\1/mi) do
    quote = Regexp.last_match(1)
    value = CGI.unescapeHTML(Regexp.last_match(2).to_s).strip
    SAFE_TARGETS.include?(value) ? %( target=#{quote}#{value}#{quote}) : ""
  end
end

.sanitizer_class ⇒ Object

Rails 7.1+ exposes Rails::HTML5::SafeListSanitizer; older versions use Rails::HTML::SafeListSanitizer.



50
51
52
53
54
55
56
# File 'lib/smeditor/rails/sanitizer.rb', line 50

def sanitizer_class
  if defined?(::Rails::HTML5::SafeListSanitizer)
    ::Rails::HTML5::SafeListSanitizer
  else
    ::Rails::HTML::SafeListSanitizer
  end
end