Class: Plugins::CamaContactForm::AdminFormsController

Inherits:
CamaleonCms::Apps::PluginsAdminController
  • Object
show all
Includes:
ContactFormControllerConcern, MainHelper
Defined in:
app/controllers/plugins/cama_contact_form/admin_forms_controller.rb

Overview

Admin CRUD for a site's contact forms; refuses (never rewrites) authored markup an untrusted role is not permitted to store.

Constant Summary collapse

MARKUP_MAIL_KEYS =

Values the author writes that reach the page verbatim. Nothing here is rewritten on save: an author either holds :manage, :contact_form_unfiltered_html and their content is stored exactly as written, or the save is rejected and they are told which field to fix.

Two kinds of position, because they fail differently:

Markup positions reach the page as element content. Unsafe means the value is not markup this
role may write, or is markup a browser would read differently from the way it was parsed here.

Attribute positions are interpolated by the renderer inside a double-quoted HTML attribute.
Unsafe means only that the value contains a double quote. Angle brackets, ampersands and
apostrophes are harmless there, so they are allowed.

Which position a value lands in is fixed rather than inferred, because the field template is authored too. placeholder_in_tag? refuses a template that puts [ci], [label ci] or [descr ci] inside a tag, so every substituted value is element content and every attribute in the emitted markup is one the renderer wrote with double quotes. That rule binds everyone, not just untrusted authors: without it a template written by a trusted author decides the context of a value written by an untrusted one, and the table below stops being true.

%w[previous_html after_html body body_answer subject subject_answer].freeze
MARKUP_FIELD_KEYS =
%w[label].freeze
MARKUP_FIELD_OPTION_KEYS =
%w[template description].freeze
ATTRIBUTE_FIELD_KEYS =

label is also emitted into name="..." by the "other" input of a radio/checkbox group.

%w[label].freeze
ATTRIBUTE_FIELD_OPTION_KEYS =
%w[field_class].freeze
ATTRIBUTE_SETTING_KEYS =

Built with a symbol key by #update, unlike the string-keyed settings around it. It reaches the page as data-sitekey="...", which the recaptcha gem interpolates without escaping.

%i[recaptcha_site_key].freeze
MAIL_SCALAR_KEYS =

Mail values the code indexes or interpolates. Held to a scalar for everyone: to_answer reaches String#gsub in the front controller and subject reaches the mailer, both after a response row has already been persisted.

%w[to subject body to_answer subject_answer body_answer
previous_html after_html].freeze
TEXTAREA_FIELD_TYPES =

The field types whose default_value is redisplayed as textarea content rather than in a value attribute, and the ones that carry a list of options. Kept in step with the renderer's case.

%w[paragraph textarea].freeze
OPTION_FIELD_TYPES =
%w[radio checkboxes dropdown select].freeze
EVENT_HANDLER_ATTR_NAME =

An attribute whose name is an event handler runs script whatever its value is. It breaks out of nothing, so neither the name-shape check nor the double-quote check below can see it: onfocus is a perfectly well-formed attribute name carrying a perfectly quote-free value.

A blunt on prefix rather than a list of known handler names, which is what HTML sanitizers use: the handler set grows with the platform, and a list that has fallen behind fails open.

/\Aon/i
HTML_ATTR_NAME =
/\A[a-zA-Z_:][-a-zA-Z0-9_:.]*\z/
URL_BEARING_ATTR_NAMES =

An attribute name is not enough on its own: formaction on a submit button, or href on a link, executes whatever scheme its value names. These carry a URL, so their value gets a scheme check; style is refused outright, because an untrusted author has no need of inline CSS and position:fixed over the viewport is a UI-redressing primitive.

%w[href src action formaction data poster srcdoc xlink:href
background dynsrc lowsrc].freeze
REFUSED_ATTR_NAMES =
%w[style].freeze
URL_IGNORABLE_CHARS =
/[[:space:]\u0000-\u001F\u007F]/
DANGEROUS_URL_SCHEME =
/\A(?:javascript|vbscript|data):/i
MARKUP_TAGS =

The safe list CamaleonCms::UnsafeMarkup scrubs these positions against: Rails' own, widened with the layout elements these positions legitimately carry. Rails' list is tuned for prose and omits label, section, fieldset and the table elements -- which is why the plugin's own default template was refused by an earlier implementation.

rel and target are deliberately NOT added. Rails omits them on purpose: rel="opener" is the explicit opt-back-in to the window.opener handle that browsers disable for target="_blank", and it hands an untrusted author the ability to repoint the visitor's original tab.

(Rails::HTML5::SafeListSanitizer.allowed_tags +
                 %w[label section article header footer main aside nav figure figcaption
fieldset legend table caption colgroup col thead tbody tfoot tr td th
time mark wbr picture source]).freeze
MARKUP_ATTRS =
(Rails::HTML5::SafeListSanitizer.allowed_attributes +
%w[id for colspan rowspan span role tabindex hidden]).freeze
TRANSLATION_MARKER =

A well-formed translation marker (<!--:-->, <!--:en-->). rendered_forms strips these to produce the marker-free string the renderer emits, so the gate judges that form too. Reused from the core detector rather than re-spelled: the markers this strips and the ones CamaleonCms::UnsafeMarkup scans around must share one grammar for the gate to stay sound, so they share one constant.

CamaleonCms::UnsafeMarkup::TRANSLATION_MARKER
PLACEHOLDER =
/\[(?:ci|label ci|descr ci)\]/
TAG_SPAN =

One whole tag, quoted attribute values included, so a > written inside an attribute does not look like the end of the tag. The closing quote is optional so an unterminated one runs to the end of the value rather than ending the match early.

%r{<[a-zA-Z/!?][^"'>]*(?:(?:"[^"]*"?|'[^']*'?)[^"'>]*)*>?}
FIELD_TYPES =

Structural values, as opposed to authored content: the form builder generates them and nobody types markup into them. They are checked against an allowlist for everyone, including an author holding the unfiltered-HTML grant -- a field whose type is text" onfocus="x is not a feature anyone wants, it is a corrupt record. Constraining them here is what lets the renderer emit them verbatim alongside everything else. select is the legacy spelling of dropdown; a form stored under an older version carries it.

%w[text paragraph textarea website email radio checkboxes dropdown select captcha file
submit button reset_button].freeze
CID_FORMAT =
/\A[a-zA-Z0-9_-]+\z/
MAX_FIELDS =

Every gated value costs a parse, and both the number of values and their size are chosen by the caller. Without these an account holding nothing but :manage, :plugins can drive several thousand Loofah parses, or one multi-megabyte parse, from a single request.

200
MAX_OPTIONS_PER_FIELD =
100
MAX_GATED_VALUE_BYTES =
64 * 1024
MESSAGE_KEYS =

The messages the form editor offers, plus invalid_content, which the gate itself emits.

Permitted rather than taken wholesale. railscf_message used to go straight from params into the gate, which runs a full Loofah parse per leaf -- so the number of parses was chosen by the caller. Permitting also stops unbounded junk being persisted into settings.to_json.

%w[mail_sent_ok mail_sent_ng validation_error invalid_required invalid_email
captcha_not_match invalid_content].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.core_markup_detector_available?Boolean

The markup gate delegates to CamaleonCms::UnsafeMarkup, which ships in camaleon_cms >= 2.9.3. That floor is not expressible as a gemspec dependency -- camaleon_cms depends on this gem, so a reverse pin would be circular -- and camaleon_cms 2.9.2 pins cama_contact_form ~> 0.1.0, wide enough to resolve this release against a core that lacks the detector. Fail fast and clearly at load, rather than with a bare NameError deep inside the first untrusted save: without the detector nothing can be gated, so the plugin must not run at all against an incompatible core.

Returns:

  • (Boolean)


15
16
17
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 15

def self.core_markup_detector_available?
  defined?(CamaleonCms::UnsafeMarkup) ? true : false
end

.ensure_core_markup_detector!Object



19
20
21
22
23
24
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 19

def self.ensure_core_markup_detector!
  return if core_markup_detector_available?

  raise "cama_contact_form #{::CamaContactForm::VERSION} requires camaleon_cms >= 2.9.3 " \
        '(CamaleonCms::UnsafeMarkup is unavailable).'
end

Instance Method Details

#createObject



43
44
45
46
47
48
49
50
51
52
53
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 43

def create
  @form = current_site.contact_forms.new(params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name,
                                                                                                             :slug))
  if @form.save
    flash[:notice] = t('.created', default: 'Created successfully').to_s
    redirect_to action: :edit, id: @form.id
  else
    flash[:error] = @form.errors.full_messages.join(', ')
    redirect_to action: :index
  end
end

#del_responseObject



107
108
109
110
111
112
113
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 107

def del_response
  response = current_site.contact_forms.find_by(id: params[:response_id])
  if response.present? && response.destroy
    flash[:notice] = t('.actions.msg_deleted', default: 'The response has been deleted').to_s
  end
  redirect_to action: :responses
end

#destroyObject



93
94
95
96
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 93

def destroy
  flash[:notice] = t('.deleted', default: 'Destroyed successfully').to_s if @form.destroy
  redirect_to action: :index
end

#editObject



38
39
40
41
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 38

def edit
  add_breadcrumb I18n.t('plugins.cama_contact_form.edit_view', default: 'Edit contact form')
  render 'edit'
end

#indexObject



31
32
33
34
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 31

def index
  @forms = current_site.contact_forms.where(parent_id: nil).all
  @forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
end

#item_fieldObject



117
118
119
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 117

def item_field
  render partial: 'item_field', locals: { field_type: params[:kind], cid: params[:cid] }
end

#manualObject



115
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 115

def manual; end

#responsesObject



98
99
100
101
102
103
104
105
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 98

def responses
  add_breadcrumb I18n.t('plugins.cama_contact_form.list_responses', default: 'Contact form records')
  @form = current_site.contact_forms.where({ id: params[:admin_form_id] }).first
  values = JSON.parse(@form.value).to_sym
  @op_fields = values[:fields].select { |field| relevant_field? field }
  @forms = current_site.contact_forms.where({ parent_id: @form.id })
  @forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
end

#showObject



36
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 36

def show; end

#updateObject



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'app/controllers/plugins/cama_contact_form/admin_forms_controller.rb', line 55

def update
  form_params = params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name, :slug)
  settings = { 'railscf_mail' => params[:railscf_mail],
               'railscf_message' => permitted_messages,
               'railscf_form_button' => params[:railscf_form_button],
               recaptcha_site_key: params[:recaptcha_site_key],
               recaptcha_secret_key: params[:recaptcha_secret_key] }

  # The shape of `params` is chosen by the client, not by the form editor, and every check below
  # indexes into it. Verified first, for everyone, so that nothing downstream -- here or in the
  # renderer -- has to cope with a String where it expected a Hash.
  if (malformed = first_malformed_shape_key(settings))
    return reject_save(t('.malformed_structure', field: malformed,
                                                 default: 'A field has a malformed %{field}. Nothing was saved.'))
  end

  fields = 

  # Checked before the record is touched, so a rejected save leaves the form exactly as it was
  # rather than persisting the name and slug and dropping everything else.
  if (malformed = first_malformed_structural_key(fields))
    return reject_save(t('.malformed_structure', field: malformed,
                                                 default: 'A field has a malformed %{field}. Nothing was saved.'))
  end

  if !trusted_for_unfiltered_html? && (rejected = first_unpermitted_html_key(settings, fields))
    return reject_save(rejection_message(*rejected))
  end

  if @form.update(form_params)
    @form.update({ settings: settings.to_json, value: { fields: fields }.to_json })
    flash[:notice] = t('.updated_success', default: 'Updated successfully')
    redirect_to action: :edit, id: @form.id
  else
    edit
  end
end