cama_contact_form 0.1.0 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +203 -0
  3. data/Rakefile +6 -21
  4. data/app/assets/javascripts/plugins/cama_contact_form/admin_editor.js +4 -4
  5. data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +240 -65
  6. data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +614 -42
  7. data/app/controllers/plugins/cama_contact_form/front_controller.rb +29 -5
  8. data/app/helpers/plugins/cama_contact_form/main_helper.rb +217 -103
  9. data/app/models/plugins/cama_contact_form/cama_contact_form.rb +60 -25
  10. data/app/views/plugins/cama_contact_form/admin_forms/_item_field.html.erb +8 -1
  11. data/app/views/plugins/cama_contact_form/forms_shorcode.html.erb +7 -3
  12. data/config/camaleon_plugin.json +2 -2
  13. data/config/initializers/custom_models.rb +5 -2
  14. data/config/locales/es.yml +8 -1
  15. data/config/locales/zh-CN.yml +7 -0
  16. data/config/routes.rb +18 -17
  17. data/lib/cama_contact_form/engine.rb +12 -0
  18. data/lib/cama_contact_form/version.rb +3 -1
  19. data/lib/cama_contact_form.rb +5 -5
  20. data/lib/tasks/cama_contact_form_tasks.rake +2 -0
  21. metadata +7 -97
  22. data/test/cama_contact_form_test.rb +0 -7
  23. data/test/dummy/README.rdoc +0 -28
  24. data/test/dummy/Rakefile +0 -6
  25. data/test/dummy/app/assets/javascripts/application.js +0 -13
  26. data/test/dummy/app/assets/stylesheets/application.css +0 -15
  27. data/test/dummy/app/controllers/application_controller.rb +0 -5
  28. data/test/dummy/app/helpers/application_helper.rb +0 -2
  29. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  30. data/test/dummy/bin/bundle +0 -3
  31. data/test/dummy/bin/rails +0 -4
  32. data/test/dummy/bin/rake +0 -4
  33. data/test/dummy/bin/setup +0 -29
  34. data/test/dummy/config/application.rb +0 -26
  35. data/test/dummy/config/boot.rb +0 -5
  36. data/test/dummy/config/database.yml +0 -25
  37. data/test/dummy/config/environment.rb +0 -5
  38. data/test/dummy/config/environments/development.rb +0 -41
  39. data/test/dummy/config/environments/production.rb +0 -79
  40. data/test/dummy/config/environments/test.rb +0 -42
  41. data/test/dummy/config/initializers/assets.rb +0 -11
  42. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  43. data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
  44. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
  45. data/test/dummy/config/initializers/inflections.rb +0 -16
  46. data/test/dummy/config/initializers/mime_types.rb +0 -4
  47. data/test/dummy/config/initializers/session_store.rb +0 -3
  48. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  49. data/test/dummy/config/locales/en.yml +0 -23
  50. data/test/dummy/config/routes.rb +0 -56
  51. data/test/dummy/config/secrets.yml +0 -22
  52. data/test/dummy/config.ru +0 -4
  53. data/test/dummy/public/404.html +0 -67
  54. data/test/dummy/public/422.html +0 -67
  55. data/test/dummy/public/500.html +0 -66
  56. data/test/dummy/public/favicon.ico +0 -0
  57. data/test/integration/navigation_test.rb +0 -8
  58. data/test/test_helper.rb +0 -20
@@ -1,84 +1,656 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin CRUD for a site's contact forms; refuses (never rewrites) authored markup an untrusted role
4
+ # is not permitted to store.
1
5
  class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::PluginsAdminController
2
6
  include Plugins::CamaContactForm::MainHelper
3
7
  include Plugins::CamaContactForm::ContactFormControllerConcern
4
- before_action :set_form, only: ['show','edit','update','destroy']
5
- add_breadcrumb I18n.t("plugins.cama_contact_form.title", default: 'Contact Form'), :admin_plugins_cama_contact_form_admin_forms_path
8
+
9
+ # The markup gate delegates to CamaleonCms::UnsafeMarkup, which ships in camaleon_cms >= 2.9.3. That
10
+ # floor is not expressible as a gemspec dependency -- camaleon_cms depends on this gem, so a reverse
11
+ # pin would be circular -- and camaleon_cms 2.9.2 pins `cama_contact_form ~> 0.1.0`, wide enough to
12
+ # resolve this release against a core that lacks the detector. Fail fast and clearly at load, rather
13
+ # than with a bare NameError deep inside the first untrusted save: without the detector nothing can
14
+ # be gated, so the plugin must not run at all against an incompatible core.
15
+ def self.core_markup_detector_available?
16
+ defined?(CamaleonCms::UnsafeMarkup) ? true : false
17
+ end
18
+
19
+ def self.ensure_core_markup_detector!
20
+ return if core_markup_detector_available?
21
+
22
+ raise "cama_contact_form #{::CamaContactForm::VERSION} requires camaleon_cms >= 2.9.3 " \
23
+ '(CamaleonCms::UnsafeMarkup is unavailable).'
24
+ end
25
+ ensure_core_markup_detector!
26
+
27
+ before_action :set_form, only: %w[show edit update destroy]
28
+ add_breadcrumb I18n.t('plugins.cama_contact_form.title', default: 'Contact Form'),
29
+ :admin_plugins_cama_contact_form_admin_forms_path
6
30
 
7
31
  def index
8
- @forms = current_site.contact_forms.where("parent_id is null").all
9
- @forms = @forms.paginate(:page => params[:page], :per_page => current_site.admin_per_page)
32
+ @forms = current_site.contact_forms.where(parent_id: nil).all
33
+ @forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
10
34
  end
11
35
 
36
+ def show; end
37
+
12
38
  def edit
13
- add_breadcrumb I18n.t("plugins.cama_contact_form.edit_view", default: 'Edit contact form')
14
- render "edit"
39
+ add_breadcrumb I18n.t('plugins.cama_contact_form.edit_view', default: 'Edit contact form')
40
+ render 'edit'
15
41
  end
16
42
 
17
- def update
18
- if @form.update(params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name, :slug))
19
- settings = {"railscf_mail" => params[:railscf_mail], "railscf_message" => params[:railscf_message], "railscf_form_button" => params[:railscf_form_button], recaptcha_site_key: params[:recaptcha_site_key], recaptcha_secret_key: params[:recaptcha_secret_key]}
20
- fields = []
21
- (params[:fields] || {}).each{|k, v|
22
- v[:field_options][:options] = v[:field_options][:options].values if v[:field_options][:options].present?
23
- fields << v
24
- }
25
- @form.update({settings: settings.to_json, value: {fields: fields}.to_json})
26
- flash[:notice] = t('.updated_success', default: 'Updated successfully')
43
+ def create
44
+ @form = current_site.contact_forms.new(params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name,
45
+ :slug))
46
+ if @form.save
47
+ flash[:notice] = t('.created', default: 'Created successfully').to_s
27
48
  redirect_to action: :edit, id: @form.id
28
49
  else
29
- edit
50
+ flash[:error] = @form.errors.full_messages.join(', ')
51
+ redirect_to action: :index
30
52
  end
31
53
  end
32
54
 
33
- def create
34
- @form = current_site.contact_forms.new(params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name, :slug))
35
- if @form.save
36
- flash[:notice] = "#{t('.created', default: 'Created successfully')}"
55
+ def update
56
+ form_params = params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name, :slug)
57
+ settings = { 'railscf_mail' => params[:railscf_mail],
58
+ 'railscf_message' => permitted_messages,
59
+ 'railscf_form_button' => params[:railscf_form_button],
60
+ recaptcha_site_key: params[:recaptcha_site_key],
61
+ recaptcha_secret_key: params[:recaptcha_secret_key] }
62
+
63
+ # The shape of `params` is chosen by the client, not by the form editor, and every check below
64
+ # indexes into it. Verified first, for everyone, so that nothing downstream -- here or in the
65
+ # renderer -- has to cope with a String where it expected a Hash.
66
+ if (malformed = first_malformed_shape_key(settings))
67
+ return reject_save(t('.malformed_structure', field: malformed,
68
+ default: 'A field has a malformed %{field}. Nothing was saved.'))
69
+ end
70
+
71
+ fields = submitted_fields
72
+
73
+ # Checked before the record is touched, so a rejected save leaves the form exactly as it was
74
+ # rather than persisting the name and slug and dropping everything else.
75
+ if (malformed = first_malformed_structural_key(fields))
76
+ return reject_save(t('.malformed_structure', field: malformed,
77
+ default: 'A field has a malformed %{field}. Nothing was saved.'))
78
+ end
79
+
80
+ if !trusted_for_unfiltered_html? && (rejected = first_unpermitted_html_key(settings, fields))
81
+ return reject_save(rejection_message(*rejected))
82
+ end
83
+
84
+ if @form.update(form_params)
85
+ @form.update({ settings: settings.to_json, value: { fields: fields }.to_json })
86
+ flash[:notice] = t('.updated_success', default: 'Updated successfully')
37
87
  redirect_to action: :edit, id: @form.id
38
88
  else
39
- flash[:error] = @form.errors.full_messages.join(', ')
40
- redirect_to action: :index
89
+ edit
41
90
  end
42
91
  end
43
92
 
44
93
  def destroy
45
- flash[:notice] = "#{t('.deleted', default: 'Destroyed successfully')}" if @form.destroy
94
+ flash[:notice] = t('.deleted', default: 'Destroyed successfully').to_s if @form.destroy
46
95
  redirect_to action: :index
47
96
  end
48
97
 
49
98
  def responses
50
- add_breadcrumb I18n.t("plugins.cama_contact_form.list_responses", default: 'Contact form records')
51
- @form = current_site.contact_forms.where({id: params[:admin_form_id]}).first
99
+ add_breadcrumb I18n.t('plugins.cama_contact_form.list_responses', default: 'Contact form records')
100
+ @form = current_site.contact_forms.where({ id: params[:admin_form_id] }).first
52
101
  values = JSON.parse(@form.value).to_sym
53
- @op_fields = values[:fields].select{ |field| relevant_field? field }
54
- @forms = current_site.contact_forms.where({parent_id: @form.id})
55
- @forms = @forms.paginate(:page => params[:page], :per_page => current_site.admin_per_page)
102
+ @op_fields = values[:fields].select { |field| relevant_field? field }
103
+ @forms = current_site.contact_forms.where({ parent_id: @form.id })
104
+ @forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
56
105
  end
57
106
 
58
107
  def del_response
59
- response = current_site.contact_forms.find_by_id(params[:response_id])
108
+ response = current_site.contact_forms.find_by(id: params[:response_id])
60
109
  if response.present? && response.destroy
61
- flash[:notice] = "#{t('.actions.msg_deleted', default: 'The response has been deleted')}"
110
+ flash[:notice] = t('.actions.msg_deleted', default: 'The response has been deleted').to_s
62
111
  end
63
112
  redirect_to action: :responses
64
113
  end
65
114
 
66
- def manual
67
-
68
- end
115
+ def manual; end
69
116
 
70
117
  def item_field
71
- render partial: 'item_field', locals:{ field_type: params[:kind], cid: params[:cid] }
118
+ render partial: 'item_field', locals: { field_type: params[:kind], cid: params[:cid] }
72
119
  end
73
120
 
74
- # here add your custom functions
121
+ # Values the author writes that reach the page verbatim. Nothing here is rewritten on save: an
122
+ # author either holds :manage, :contact_form_unfiltered_html and their content is stored exactly as
123
+ # written, or the save is rejected and they are told which field to fix.
124
+ #
125
+ # Two kinds of position, because they fail differently:
126
+ #
127
+ # Markup positions reach the page as element content. Unsafe means the value is not markup this
128
+ # role may write, or is markup a browser would read differently from the way it was parsed here.
129
+ #
130
+ # Attribute positions are interpolated by the renderer inside a double-quoted HTML attribute.
131
+ # Unsafe means only that the value contains a double quote. Angle brackets, ampersands and
132
+ # apostrophes are harmless there, so they are allowed.
133
+ #
134
+ # Which position a value lands in is fixed rather than inferred, because the field `template` is
135
+ # authored too. `placeholder_in_tag?` refuses a template that puts `[ci]`, `[label ci]` or
136
+ # `[descr ci]` inside a tag, so every substituted value is element content and every attribute in
137
+ # the emitted markup is one the renderer wrote with double quotes. That rule binds everyone, not
138
+ # just untrusted authors: without it a template written by a trusted author decides the context of
139
+ # a value written by an untrusted one, and the table below stops being true.
140
+ MARKUP_MAIL_KEYS = %w[previous_html after_html body body_answer subject subject_answer].freeze
141
+ MARKUP_FIELD_KEYS = %w[label].freeze
142
+ MARKUP_FIELD_OPTION_KEYS = %w[template description].freeze
143
+
144
+ # `label` is also emitted into `name="..."` by the "other" input of a radio/checkbox group.
145
+ ATTRIBUTE_FIELD_KEYS = %w[label].freeze
146
+ ATTRIBUTE_FIELD_OPTION_KEYS = %w[field_class].freeze
147
+
148
+ # Built with a symbol key by #update, unlike the string-keyed settings around it. It reaches the
149
+ # page as `data-sitekey="..."`, which the recaptcha gem interpolates without escaping.
150
+ ATTRIBUTE_SETTING_KEYS = %i[recaptcha_site_key].freeze
151
+
152
+ # Mail values the code indexes or interpolates. Held to a scalar for everyone: `to_answer` reaches
153
+ # `String#gsub` in the front controller and `subject` reaches the mailer, both after a response row
154
+ # has already been persisted.
155
+ MAIL_SCALAR_KEYS = %w[to subject body to_answer subject_answer body_answer
156
+ previous_html after_html].freeze
157
+
158
+ # The field types whose default_value is redisplayed as textarea content rather than in a value
159
+ # attribute, and the ones that carry a list of options. Kept in step with the renderer's `case`.
160
+ TEXTAREA_FIELD_TYPES = %w[paragraph textarea].freeze
161
+ OPTION_FIELD_TYPES = %w[radio checkboxes dropdown select].freeze
162
+
163
+ # An attribute whose *name* is an event handler runs script whatever its value is. It breaks out of
164
+ # nothing, so neither the name-shape check nor the double-quote check below can see it: `onfocus`
165
+ # is a perfectly well-formed attribute name carrying a perfectly quote-free value.
166
+ #
167
+ # A blunt `on` prefix rather than a list of known handler names, which is what HTML sanitizers use:
168
+ # the handler set grows with the platform, and a list that has fallen behind fails open.
169
+ EVENT_HANDLER_ATTR_NAME = /\Aon/i
170
+ HTML_ATTR_NAME = /\A[a-zA-Z_:][-a-zA-Z0-9_:.]*\z/
171
+
172
+ # An attribute name is not enough on its own: `formaction` on a submit button, or `href` on a link,
173
+ # executes whatever scheme its value names. These carry a URL, so their value gets a scheme check;
174
+ # `style` is refused outright, because an untrusted author has no need of inline CSS and
175
+ # `position:fixed` over the viewport is a UI-redressing primitive.
176
+ URL_BEARING_ATTR_NAMES = %w[href src action formaction data poster srcdoc xlink:href
177
+ background dynsrc lowsrc].freeze
178
+ REFUSED_ATTR_NAMES = %w[style].freeze
179
+ URL_IGNORABLE_CHARS = /[[:space:]\u0000-\u001F\u007F]/
180
+ DANGEROUS_URL_SCHEME = /\A(?:javascript|vbscript|data):/i
181
+
182
+ # The safe list CamaleonCms::UnsafeMarkup scrubs these positions against: Rails' own, widened with
183
+ # the layout elements these positions legitimately carry. Rails' list is tuned for prose and omits
184
+ # `label`, `section`, `fieldset` and the table elements -- which is why the plugin's own default
185
+ # template was refused by an earlier implementation.
186
+ #
187
+ # `rel` and `target` are deliberately NOT added. Rails omits them on purpose: `rel="opener"` is the
188
+ # explicit opt-back-in to the `window.opener` handle that browsers disable for `target="_blank"`,
189
+ # and it hands an untrusted author the ability to repoint the visitor's original tab.
190
+ MARKUP_TAGS = (Rails::HTML5::SafeListSanitizer.allowed_tags +
191
+ %w[label section article header footer main aside nav figure figcaption
192
+ fieldset legend table caption colgroup col thead tbody tfoot tr td th
193
+ time mark wbr picture source]).freeze
194
+ MARKUP_ATTRS = (Rails::HTML5::SafeListSanitizer.allowed_attributes +
195
+ %w[id for colspan rowspan span role tabindex hidden]).freeze
196
+
197
+ # A well-formed translation marker (`<!--:-->`, `<!--:en-->`). `rendered_forms` strips these to
198
+ # produce the marker-free string the renderer emits, so the gate judges that form too. Reused from
199
+ # the core detector rather than re-spelled: the markers this strips and the ones
200
+ # CamaleonCms::UnsafeMarkup scans around must share one grammar for the gate to stay sound, so they
201
+ # share one constant.
202
+ TRANSLATION_MARKER = CamaleonCms::UnsafeMarkup::TRANSLATION_MARKER
203
+
204
+ PLACEHOLDER = /\[(?:ci|label ci|descr ci)\]/
205
+
206
+ # One whole tag, quoted attribute values included, so a `>` written inside an attribute does not
207
+ # look like the end of the tag. The closing quote is optional so an unterminated one runs to the
208
+ # end of the value rather than ending the match early.
209
+ TAG_SPAN = %r{<[a-zA-Z/!?][^"'>]*(?:(?:"[^"]*"?|'[^']*'?)[^"'>]*)*>?}
210
+
211
+ # Structural values, as opposed to authored content: the form builder generates them and nobody
212
+ # types markup into them. They are checked against an allowlist for *everyone*, including an author
213
+ # holding the unfiltered-HTML grant -- a field whose type is `text" onfocus="x` is not a feature
214
+ # anyone wants, it is a corrupt record. Constraining them here is what lets the renderer emit them
215
+ # verbatim alongside everything else.
216
+ # `select` is the legacy spelling of `dropdown`; a form stored under an older version carries it.
217
+ FIELD_TYPES = %w[text paragraph textarea website email radio checkboxes dropdown select captcha file
218
+ submit button reset_button].freeze
219
+ CID_FORMAT = /\A[a-zA-Z0-9_-]+\z/
220
+
221
+ # Every gated value costs a parse, and both the number of values and their size are chosen by the
222
+ # caller. Without these an account holding nothing but :manage, :plugins can drive several thousand
223
+ # Loofah parses, or one multi-megabyte parse, from a single request.
224
+ MAX_FIELDS = 200
225
+ MAX_OPTIONS_PER_FIELD = 100
226
+ MAX_GATED_VALUE_BYTES = 64 * 1024
227
+
228
+ # The messages the form editor offers, plus `invalid_content`, which the gate itself emits.
229
+ #
230
+ # Permitted rather than taken wholesale. `railscf_message` used to go straight from params into the
231
+ # gate, which runs a full Loofah parse per leaf -- so the number of parses was chosen by the caller.
232
+ # Permitting also stops unbounded junk being persisted into `settings.to_json`.
233
+ MESSAGE_KEYS = %w[mail_sent_ok mail_sent_ng validation_error invalid_required invalid_email
234
+ captcha_not_match invalid_content].freeze
235
+
75
236
  private
76
- def set_form
77
- begin
78
- @form = current_site.contact_forms.find_by_id(params[:id])
79
- rescue
80
- flash[:error] = "Error form class"
81
- redirect_to cama_admin_path
237
+
238
+ # Mirrors CamaleonCms::Post#trusted_for_unfiltered_html?: read the acting user and site from
239
+ # CurrentRequest and fail closed when either is missing, so saves from background jobs, rake tasks
240
+ # or the console are treated as untrusted. The site is guarded as well because Ability#initialize
241
+ # dereferences it for non-admin users and would otherwise raise mid-save.
242
+ def trusted_for_unfiltered_html?
243
+ user = CurrentRequest.user
244
+ site = CurrentRequest.site
245
+ return false if user.blank? || site.blank?
246
+
247
+ CamaleonCms::Ability.new(user, site).can?(:manage, :contact_form_unfiltered_html)
248
+ end
249
+
250
+ # Normalizes the submitted fields into the list that is stored. Shape is already known good --
251
+ # `first_malformed_shape_key` refuses a non-hash field or field_options rather than dropping it,
252
+ # so nothing here silently discards what the author typed.
253
+ def submitted_fields
254
+ raw = params[:fields]
255
+ return [] unless hashish?(raw)
256
+
257
+ raw.each_value.map do |v|
258
+ options = at(v, :field_options)
259
+ v[:field_options] = options = ActionController::Parameters.new if options.blank?
260
+ options[:options] = options[:options].values if hashish?(options[:options])
261
+ v
262
+ end
263
+ end
264
+
265
+ # Returns `[name, rule]` for the first value an untrusted author may not save, or nil.
266
+ #
267
+ # `name` is only ever a key from the lists above -- never the value, and never the field's label.
268
+ # The message built from it is rendered with `raw` by Camaleon's admin flash partial, so echoing
269
+ # back the content that was just rejected would make the rejection itself an injection sink.
270
+ #
271
+ # `rule` names which test refused it, so the message can say what to remove. Telling an author who
272
+ # typed `btn "primary"` into Custom Class that it "contains HTML" sends them looking for markup
273
+ # that is not there, and points them at a permission that would not have applied.
274
+ #
275
+ # Stops at the first offender rather than collecting them all: nothing is saved either way.
276
+ def first_unpermitted_html_key(settings, fields)
277
+ mail = settings['railscf_mail']
278
+ MARKUP_MAIL_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(mail, k)) }
279
+ return ['submit button label', :markup] if unsafe_markup?(at(settings['railscf_form_button'], 'name_button'))
280
+ return ['response message', :markup] if unsafe_messages?(settings['railscf_message'])
281
+
282
+ ATTRIBUTE_SETTING_KEYS.each { |k| return [k.to_s, :attribute] if unsafe_attribute?(settings[k]) }
283
+
284
+ fields.each do |field|
285
+ MARKUP_FIELD_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(field, k)) }
286
+ ATTRIBUTE_FIELD_KEYS.each { |k| return [k, :attribute] if unsafe_attribute?(at(field, k)) }
287
+ if (rule = unsafe_default_value_rule(field))
288
+ return ['default_value', rule]
289
+ end
290
+
291
+ options = at(field, :field_options)
292
+ next if options.blank?
293
+
294
+ MARKUP_FIELD_OPTION_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(options, k)) }
295
+ ATTRIBUTE_FIELD_OPTION_KEYS.each { |k| return [k, :attribute] if unsafe_attribute?(at(options, k)) }
296
+ return ['field_attributes', :attributes_json] if unsafe_attribute_json?(at(options, 'field_attributes'))
297
+
298
+ # An option label reaches BOTH positions no matter what the template says: it is element
299
+ # content inside the `<label>`, and the renderer also derives the control's `value="..."`
300
+ # from it. So it carries both rules, unlike `description`, which the template can only ever
301
+ # place as element content.
302
+ Array(at(options, :options)).each do |option|
303
+ label = at(option, 'label')
304
+ return ['option label', :markup] if unsafe_markup?(label)
305
+ return ['option label', :attribute] if unsafe_attribute?(label)
306
+ end
82
307
  end
308
+
309
+ nil
310
+ end
311
+
312
+ # Reads a key only from something that actually has keys. `try(:[], k)` looked equivalent and was
313
+ # not: on a String it reaches `String#[]`, which is substring search, so a scalar submitted where a
314
+ # hash was expected returned nil and every check on it reported safe.
315
+ def at(container, key)
316
+ return nil unless hashish?(container)
317
+
318
+ container[key]
319
+ end
320
+
321
+ def hashish?(value)
322
+ value.is_a?(Hash) || value.is_a?(ActionController::Parameters)
323
+ end
324
+
325
+ def scalarish?(value)
326
+ value.nil? || value.is_a?(String) || value.is_a?(Numeric)
327
+ end
328
+
329
+ def oversized?(value)
330
+ value.to_s.bytesize > MAX_GATED_VALUE_BYTES
331
+ end
332
+
333
+ # Refusals use flash.now, not flash: this renders rather than redirecting, so a plain flash would
334
+ # survive into the *next* page the author visits and report a failure that is no longer happening.
335
+ #
336
+ # Everything the author typed is carried back, so a refused save does not discard their work. The
337
+ # record is assigned but never saved, and `#fields`/`#the_settings` memoize off the assigned
338
+ # attributes, so `edit.html.erb` redraws the submitted form rather than the stored one.
339
+ def reject_save(message)
340
+ flash.now[:error] = message
341
+ @form.assign_attributes(name: params.dig(:plugins_cama_contact_form_cama_contact_form, :name),
342
+ slug: params.dig(:plugins_cama_contact_form_cama_contact_form, :slug))
343
+ repopulate_editor
344
+ edit
345
+ end
346
+
347
+ # Normalized to the shape `edit.html.erb` reads, not handed back raw: these are by definition the
348
+ # params that just failed validation, so a field may be a bare string or an option list a hash.
349
+ # A malformed field is dropped from the redraw rather than crashing it -- the message already names
350
+ # what was wrong with it. Best effort throughout: a refusal is never worth a second exception.
351
+ def repopulate_editor
352
+ submitted = params[:fields]
353
+ if hashish?(submitted)
354
+ redrawn = submitted.each_value.select { |field| hashish?(field) }.map do |field|
355
+ field = field.to_unsafe_h.dup if field.respond_to?(:to_unsafe_h)
356
+ options = field[:field_options]
357
+ field[:field_options] = hashish?(options) ? options : {}
358
+ field
359
+ end
360
+ @form.value = { fields: redrawn }.to_json
361
+ end
362
+ @form.settings = { 'railscf_mail' => params[:railscf_mail],
363
+ 'railscf_message' => params[:railscf_message],
364
+ 'railscf_form_button' => params[:railscf_form_button],
365
+ recaptcha_site_key: params[:recaptcha_site_key],
366
+ recaptcha_secret_key: params[:recaptcha_secret_key] }.to_json
367
+ rescue StandardError
368
+ nil
369
+ end
370
+
371
+ def rejection_message(rejected, rule)
372
+ case rule
373
+ when :attribute
374
+ t('.unsafe_attribute_rejected', field: rejected,
375
+ default: 'The %{field} contains a double quote, which would break the HTML ' \
376
+ 'attribute it is written into. Nothing was saved. Remove it, or ask ' \
377
+ 'an administrator to grant the "Allow unfiltered HTML in contact ' \
378
+ 'forms" permission.')
379
+ when :textarea
380
+ t('.unsafe_textarea_rejected', field: rejected,
381
+ default: 'The %{field} closes the text box it is written into. Nothing was ' \
382
+ 'saved. Remove the closing tag, or ask an administrator to grant the ' \
383
+ '"Allow unfiltered HTML in contact forms" permission.')
384
+ when :attributes_json
385
+ t('.unsafe_attributes_rejected', field: rejected,
386
+ default: 'The %{field} contains an attribute your role is not permitted to ' \
387
+ 'save: an event handler, an inline style, a script URL, or a ' \
388
+ 'malformed name. Nothing was saved. Remove it, or ask an ' \
389
+ 'administrator to grant the "Allow unfiltered HTML in contact forms" ' \
390
+ 'permission.')
391
+ else
392
+ t('.unfiltered_html_rejected', field: rejected,
393
+ default: 'The %{field} contains HTML that your role is not permitted to save. ' \
394
+ 'Nothing was saved. Remove it, or ask an administrator to grant the ' \
395
+ '"Allow unfiltered HTML in contact forms" permission.')
396
+ end
397
+ end
398
+
399
+ # The containers the gate and the renderer both index into. Checked for everyone, before anything
400
+ # else, because a wrong shape here is not an authoring mistake to be judged by permission -- it is
401
+ # a record neither side can read. A missing container is fine (a partial update leaves it unset,
402
+ # and the model's readers default it); a container of the wrong type is not.
403
+ def first_malformed_shape_key(settings)
404
+ # `nil?`, not `blank?`: an absent `fields` means the caller is not editing them, but an empty
405
+ # string is a shape the editor never sends -- and `("" || {}).each` is a NoMethodError, because
406
+ # `""` is truthy.
407
+ fields_param = params[:fields]
408
+ return 'form fields' unless fields_param.nil? || hashish?(fields_param)
409
+
410
+ if hashish?(fields_param)
411
+ return 'field count' if fields_param.keys.size > MAX_FIELDS
412
+
413
+ fields_param.each_value do |field|
414
+ return 'form field' unless hashish?(field)
415
+
416
+ options = at(field, :field_options)
417
+ return 'field options' unless options.blank? || hashish?(options)
418
+ end
419
+ end
420
+
421
+ { 'railscf_mail' => 'mail settings', 'railscf_message' => 'response message',
422
+ 'railscf_form_button' => 'submit button label' }.each do |key, name|
423
+ value = settings[key]
424
+ return name unless value.nil? || hashish?(value)
425
+ end
426
+
427
+ MAIL_SCALAR_KEYS.each do |key|
428
+ value = at(settings['railscf_mail'], key)
429
+ return key unless scalarish?(value)
430
+ return key if oversized?(value)
431
+ end
432
+
433
+ button = at(settings['railscf_form_button'], 'name_button')
434
+ return 'submit button label' unless scalarish?(button)
435
+ return 'submit button label' if oversized?(button)
436
+
437
+ each_leaf(settings['railscf_message']) { |v| return 'response message' if oversized?(v) }
438
+
439
+ ATTRIBUTE_SETTING_KEYS.each do |key|
440
+ return key.to_s unless settings[key].blank? || settings[key].is_a?(String)
441
+ return key.to_s if oversized?(settings[key])
442
+ end
443
+
444
+ nil
445
+ end
446
+
447
+ # default_value lands in whichever position its field type renders: a paragraph or textarea
448
+ # redisplays it as textarea content, every other type puts it in a double-quoted value attribute.
449
+ # Sound only because `placeholder_in_tag?` guarantees `[ci]` is element content -- otherwise a
450
+ # template of `<div title="[ci]">` puts the whole textarea inside an attribute and neither rule
451
+ # describes the position. Returns the rule that refused it, or nil.
452
+ def unsafe_default_value_rule(field)
453
+ value = at(field, 'default_value')
454
+ if TEXTAREA_FIELD_TYPES.include?(at(field, :field_type).to_s)
455
+ unsafe_textarea?(value) ? :textarea : nil
456
+ else
457
+ unsafe_attribute?(value) ? :attribute : nil
458
+ end
459
+ end
460
+
461
+ def permitted_messages
462
+ messages = params[:railscf_message]
463
+ return messages unless messages.respond_to?(:permit)
464
+
465
+ messages.permit(*MESSAGE_KEYS)
466
+ end
467
+
468
+ # Every railscf_message value reaches the public page. The front controller joins them into
469
+ # flash[:contact_form] on both the failure and the success path, and the frontend flash partial
470
+ # renders that with `raw`.
471
+ def unsafe_messages?(messages)
472
+ each_leaf(messages).any? { |value| unsafe_markup?(value) }
473
+ end
474
+
475
+ # Lazy, so the first offending leaf stops the walk.
476
+ def each_leaf(value, &block)
477
+ return enum_for(:each_leaf, value) unless block
478
+
479
+ case value
480
+ when ActionController::Parameters, Hash then value.each_value { |v| each_leaf(v, &block) }
481
+ when Array then value.each { |v| each_leaf(v, &block) }
482
+ else yield value
483
+ end
484
+ end
485
+
486
+ # Returns the name of the first malformed structural value, or nil. Same rule: the key only, never
487
+ # the value the author submitted.
488
+ def first_malformed_structural_key(fields)
489
+ fields.each do |field|
490
+ return 'cid' unless at(field, :cid).to_s.match?(CID_FORMAT)
491
+
492
+ type = at(field, :field_type).to_s
493
+ return 'field type' unless FIELD_TYPES.include?(type)
494
+ return 'required flag' unless scalarish?(at(field, :required))
495
+
496
+ options = at(field, :field_options)
497
+ maxlength = at(options, 'maxlength')
498
+ return 'maxlength' if maxlength.present? && !maxlength.to_s.match?(/\A\d+\z/)
499
+
500
+ option_list = Array(at(options, :options))
501
+ return 'option count' if option_list.size > MAX_OPTIONS_PER_FIELD
502
+
503
+ # A field type that renders a list of choices needs at least one, and every option needs a
504
+ # label: `options.each` and `op[:label].translate` are both unguarded in the renderer, so
505
+ # either omission is a NoMethodError on every visit, from a save the author was told succeeded.
506
+ return 'options' if OPTION_FIELD_TYPES.include?(type) && option_list.empty?
507
+
508
+ option_list.each do |option|
509
+ return 'option label' unless hashish?(option) && at(option, 'label').is_a?(String)
510
+ return 'option label' if oversized?(at(option, 'label'))
511
+ end
512
+
513
+ # Every substituted slot must be a string for the same reason: `["<div>[ci]</div>"].to_s` is
514
+ # sanitize-stable, so an array-valued template passes the content gate and then reaches
515
+ # `String#sub` in the renderer.
516
+ (MARKUP_FIELD_KEYS + ATTRIBUTE_FIELD_KEYS + %w[default_value]).each do |k|
517
+ return k unless scalarish?(at(field, k))
518
+ return k if oversized?(at(field, k))
519
+ end
520
+ (MARKUP_FIELD_OPTION_KEYS + ATTRIBUTE_FIELD_OPTION_KEYS + %w[field_attributes]).each do |k|
521
+ return k unless scalarish?(at(options, k))
522
+ return k if oversized?(at(options, k))
523
+ end
524
+
525
+ # Binds everyone, including a grant holder. A placeholder inside a tag makes the substituted
526
+ # value's HTML context depend on who wrote the template rather than on what the value is, and
527
+ # `field_attributes` that is not a JSON object reaches `{id: cid}.merge(...)` in the renderer
528
+ # and raises TypeError on every public page.
529
+ return 'template' if placeholder_in_tag?(at(options, 'template'))
530
+ return 'field_attributes' if malformed_attribute_json?(at(options, 'field_attributes'))
531
+ end
532
+
533
+ nil
534
+ end
535
+
536
+ # Every gated value is checked in each of the forms it can reach the page as, not only as written.
537
+ # `String#translate` picks a locale-specific substring and *deletes* the `<!--:xx-->` markers on the
538
+ # way, so the renderer emits a string the gate never saw. Checking only the stored form let an
539
+ # author split a payload across a marker -- `</tex<!--:en-->tarea>` -- and have the renderer put it
540
+ # back together.
541
+ def rendered_forms(value)
542
+ string = value.to_s
543
+ return [] if string.blank?
544
+
545
+ ([string] + string.translations_array.map(&:to_s) + [string.gsub(TRANSLATION_MARKER, '')]).uniq
546
+ rescue StandardError
547
+ [string]
548
+ end
549
+
550
+ # Each rendered form is judged by CamaleonCms::UnsafeMarkup, the core scan-and-reject detector this
551
+ # gate is kept in parity with. It parses once and refuses the value when the safe-list scrubber
552
+ # would remove anything, when a kept attribute smuggles markup (`title="&lt;img ...&gt;"` reaching a
553
+ # `data-html` sink), when dangerous inline CSS survives, or when the value carries one of the
554
+ # structural shapes an HTML parser silently repairs -- an unterminated tag or attribute, a
555
+ # foster-parented table cell, a translation marker inside a tag. MARKUP_TAGS/MARKUP_ATTRS widen
556
+ # Rails' safe list with the layout elements these positions carry; `data-`/`aria-` attributes are
557
+ # admitted by shape by the detector itself.
558
+ def unsafe_markup?(value)
559
+ rendered_forms(value).any? do |form|
560
+ CamaleonCms::UnsafeMarkup.unsafe_html?(form, tags: MARKUP_TAGS, attributes: MARKUP_ATTRS)
561
+ end
562
+ end
563
+
564
+ # A template must not put one of the renderer's own placeholders inside a tag: doing so decides the
565
+ # HTML context of a value written somewhere else, possibly by someone else. Structural rather than
566
+ # parse-based (see token_inside_tag?), because an HTML parser silently repairs the shape.
567
+ def placeholder_in_tag?(value)
568
+ string = value.to_s
569
+ return false unless string.include?('[')
570
+
571
+ token_inside_tag?(string, PLACEHOLDER)
572
+ end
573
+
574
+ # Collects the value's tags and reports whether `token` appears inside any of them. Structural
575
+ # rather than parse-based, because an HTML parser silently repairs both of the shapes this exists
576
+ # to catch. `token` is listed first in the alternation so a token standing on its own is consumed
577
+ # as itself and not mistaken for a tag; one swallowed by a real tag span is not.
578
+ def token_inside_tag?(string, token)
579
+ tags = +''
580
+ string.scan(/#{token}|#{TAG_SPAN}/) { |match| tags << match unless match.match?(/\A#{token}\z/) }
581
+ tags.match?(token)
582
+ end
583
+
584
+ def unsafe_attribute?(value)
585
+ rendered_forms(value).any? { |form| form.include?('"') }
586
+ end
587
+
588
+ # RCDATA: only the closing tag ends a textarea, so that sequence is the whole of what is refused.
589
+ # Quotes, ampersands and angle brackets are ordinary characters in a prefilled message.
590
+ def unsafe_textarea?(value)
591
+ rendered_forms(value).any? { |form| form.downcase.include?('</textarea') }
592
+ end
593
+
594
+ # Valid JSON that is not an object. Structural, because the renderer's `rescue {}` binds only to
595
+ # `JSON.parse`, so a well-formed `[1,2]` parses, reaches `{id: cid}.merge(...)` and raises
596
+ # TypeError -- a permanent 500 on every public page, from a save reported as successful. Held to
597
+ # everyone, grant holders included: it is a corrupt record, not a capability.
598
+ def malformed_attribute_json?(value)
599
+ return false if value.blank?
600
+
601
+ parsed = parse_attribute_json(value)
602
+ return false if parsed == :unparseable
603
+
604
+ !parsed.is_a?(Hash)
605
+ end
606
+
607
+ # field_attributes is a JSON object emitted verbatim as attribute name/value pairs, so an untrusted
608
+ # author may write neither a name that escapes the tag nor one that is itself script:
609
+ #
610
+ # a name that is not a valid attribute name renders as several attributes;
611
+ # a value containing a double quote closes its own;
612
+ # a name beginning with `on` is an event handler and runs whatever the value says;
613
+ # a name carrying a URL executes whatever scheme that URL names;
614
+ # `style` is refused outright, being enough on its own to cover the viewport with an overlay.
615
+ def unsafe_attribute_json?(value)
616
+ return false if value.blank?
617
+
618
+ parsed = parse_attribute_json(value)
619
+ return false unless parsed.is_a?(Hash)
620
+
621
+ parsed.any? { |k, v| unsafe_attribute_pair?(k, v) }
622
+ end
623
+
624
+ def parse_attribute_json(value)
625
+ JSON.parse(value.to_s)
626
+ rescue StandardError
627
+ :unparseable
628
+ end
629
+
630
+ def unsafe_attribute_pair?(name, value)
631
+ name = name.to_s
632
+ return true unless name.match?(HTML_ATTR_NAME)
633
+ return true if name.match?(EVENT_HANDLER_ATTR_NAME)
634
+ return true if REFUSED_ATTR_NAMES.include?(name.downcase)
635
+ return true if value.to_s.include?('"')
636
+
637
+ URL_BEARING_ATTR_NAMES.include?(name.downcase) && dangerous_url?(value)
638
+ end
639
+
640
+ # Decoded the way the browser will decode it. `CGI.unescapeHTML` handles only the five legacy
641
+ # entities and numeric references, so `javascript&colon;alert(1)` and `java&Tab;script:alert(1)`
642
+ # walked straight past a hand-rolled check -- and the HTML parser resolves both to `javascript:`.
643
+ # The parser is the authority on that question, so it is the one asked. The value is known to
644
+ # carry no double quote by the time this runs.
645
+ def dangerous_url?(value)
646
+ decoded = Loofah.html5_fragment(%(<a href="#{value}">)).at_css('a')&.[]('href').to_s
647
+ decoded.gsub(URL_IGNORABLE_CHARS, '').match?(DANGEROUS_URL_SCHEME)
648
+ end
649
+
650
+ def set_form
651
+ @form = current_site.contact_forms.find_by(id: params[:id])
652
+ rescue StandardError
653
+ flash[:error] = t('.error_form_class', default: 'Error form class')
654
+ redirect_to cama_admin_path
83
655
  end
84
656
  end