Module: Plugins::CamaContactForm::ContactFormControllerConcern

Included in:
AdminFormsController, FrontController
Defined in:
app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb

Constant Summary collapse

ECHOED_ATTRIBUTE_FIELD_TYPES =

The field types whose submitted value the renderer interpolates back into the page, and the position each one lands in. Everything else -- radio, checkboxes, dropdown, file -- is only ever compared against, never interpolated, so nothing it contains can escape anything.

%w[text website email].freeze
ECHOED_TEXTAREA_FIELD_TYPES =
%w[paragraph textarea].freeze
ACTIVE_ELEMENTS =

Elements that do something rather than say something, wherever they appear.

%w[script style iframe object embed applet frame frameset form input button
link meta base svg math template].freeze
ACTIVE_ELEMENT =
/<\s*\/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b/i
EVENT_HANDLER_IN_TAG =
/<[a-zA-Z][^>]*\son[a-zA-Z]+\s*=/im
URL_SCHEME_IN_TAG =
%r{<[a-zA-Z][^>]*\b(?:href|src|action|formaction|data|poster|srcdoc|background)\s*=\s*
["']?\s*(?:javascript|vbscript|data)\s*:}imx

Instance Method Summary collapse

Instance Method Details

#active_markup?(string) ⇒ Boolean

Returns:

  • (Boolean)


127
128
129
130
131
132
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 127

def active_markup?(string)
  string = string.to_s
  return false unless string.include?('<')

  string.match?(ACTIVE_ELEMENT) || string.match?(EVENT_HANDLER_IN_TAG) || string.match?(URL_SCHEME_IN_TAG)
end

#compute_unsafe_submitted?(form, fields) ⇒ Boolean

Fails closed on a missing form: there is nothing to judge the submission against, and the caller must not go on to stash it.

Returns:

  • (Boolean)


73
74
75
76
77
78
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 73

def (form, fields)
  return true if form.blank?
  return true unless fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters)

  form.fields.any? { |f| (f, fields[f[:cid].to_sym]) }
end

#convert_form_values(form, fields) ⇒ Object

form values with labels + values to save



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 195

def convert_form_values(form, fields)
  values = {}
  form.fields.each do |field|
    next unless relevant_field?(field)
    ft = field[:field_type]
    cid = field[:cid].to_sym
    label = values.keys.include?(field[:label]) ? "#{field[:label]} (#{cid})" : field[:label].to_s.translate
    values[label] = []
    if ft == 'file'
      nr_files = Array(fields[cid]).size
      values[label] << "#{nr_files} #{"file".pluralize(nr_files)} (attached)" if fields[cid].present?
    elsif ft == 'radio' || ft == 'checkboxes'
      values[label] << Array(fields[cid]).map { |f| f.to_s.translate }.join(', ') if fields[cid].present?
    else
      values[label] << fields[cid] if fields[cid].present?
    end
  end
  values
end

#echoed_value(value) ⇒ Object

The exact string the renderer will emit. values[cid] is interpolated directly, so a non-String is judged by its to_s -- which is how an Array or a Hash supplies the double quote that closes the attribute without the payload needing one of its own. Judging the leaves instead of the whole missed that; judging Array#to_s for every type refused every checkbox and every file upload, because Array#to_s is inspect and always carries quotes.



111
112
113
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 111

def echoed_value(value)
  value.is_a?(String) ? value : value.to_s
end

#perform_save_form(form, fields, success, errors) ⇒ Object



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 17

def perform_save_form(form, fields, success, errors)
  attachments = []
  if validate_to_save_form(form, fields, errors)
    form.fields.each do |f|
      if f[:field_type] == 'file'
        file_paths = []
        fields[f[:cid].to_sym].to_a.each do |file|
          res = cama_tmp_upload(file, {
              maximum: current_site.get_option('filesystem_max_size', 100).megabytes,
              path: Rails.public_path.join("contact_form", current_site.id.to_s),
              name: file.original_filename
            }
          )
          if res[:error].present?
            errors << res[:error].to_s.translate
          else
            attachments << res[:file_path]
            file_paths << res[:file_path].sub(Rails.public_path.to_s, cama_root_url)
          end
        end
        fields[f[:cid].to_sym] = file_paths
      end
    end
    new_settings = {"fields" => fields, "created_at" => Time.current.strftime("%Y-%m-%d %H:%M:%S").to_s}.to_json
    form_new = current_site.contact_forms.new(name: "response-#{Time.now}", description: form.description, settings: new_settings, site_id: form.site_id, parent_id: form.id)
    if form_new.save
      fields_data = convert_form_values(form, fields)
      message_body = form.mail_settings[:body].to_s.translate.cama_replace_codes(fields)
      content = render_to_string(partial: plugin_view('contact_form/email_content'), layout: false, formats: [:html], locals: {file_attachments: attachments, fields: fields_data, values: fields, message_body: message_body, form: form})
      cama_send_email(form.mail_settings[:to], form.mail_settings[:subject].to_s.translate.cama_replace_codes(fields), {attachments: attachments, content: content, extra_data: {fields: fields_data}})
      success << form.the_message('mail_sent_ok', t('.success_form_val', default: 'Your message has been sent successfully. Thank you very much!'))
      args = {form: form, values: fields}; hooks_run("contact_form_after_submit", args)
      if form.mail_settings[:to_answer].present? && (answer_to = fields[form.mail_settings[:to_answer].to_s.gsub(/(\[|\])/, '').to_sym]).present?
        content = form.mail_settings[:body_answer].to_s.translate.cama_replace_codes(fields)
        cama_send_email(answer_to, form.mail_settings[:subject_answer].to_s.translate.cama_replace_codes(fields), {content: content})
      end
    else
      errors << form.the_message('mail_sent_ng', t('.error_form_val', default: 'An error occurred, please try again.'))
    end
  end
end

#relevant_field?(field) ⇒ Boolean

Returns:

  • (Boolean)


215
216
217
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 215

def relevant_field?(field)
  !%w(captcha submit button).include? field[:field_type]
end

#submitted_leaves(value) ⇒ Object

An uploaded file is not redisplayed and has no string form worth inspecting; everything else is judged as the mail body will interpolate it.



117
118
119
120
121
122
123
124
125
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 117

def (value)
  case value
  when ActionController::Parameters then (value.to_unsafe_h)
  when Hash then value.values.flat_map { |v| (v) }
  when Array then value.flat_map { |v| (v) }
  when ActionDispatch::Http::UploadedFile then []
  else [value.to_s]
  end
end

#unsafe_submitted?(form, fields) ⇒ Boolean

A visitor's submission is rejected, not escaped, for the same reason an untrusted author's is: what is stored and redisplayed then equals what was submitted, so rendering it verbatim adds nothing.

Memoized because both the validation and the redisplay decision ask the same question about the same two objects in a single request, and the walk is over attacker-chosen structure.

Returns:

  • (Boolean)


65
66
67
68
69
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 65

def (form, fields)
  return @_cf_unsafe_submitted unless @_cf_unsafe_submitted.nil?

  @_cf_unsafe_submitted = (form, fields)
end

#unsafe_submitted_field?(field, value) ⇒ Boolean

Two sinks, judged separately because they are different contexts:

The notification e-mail interpolates every submitted value into an author-written body and
renders the result with `raw`, so a value carrying active markup is refused whatever its type.
The test is deliberately narrow -- script, an event handler, a script URL -- and not a
sanitizer, which would reject ordinary prose: a visitor writing `Fish & Chips <today>` would be
told their message is not allowed, which is both wrong and infuriating.

The redisplayed form interpolates the value only for the types listed above, and only into a
double-quoted value attribute or into `<textarea>` content. A paragraph is RCDATA, where markup
is literal text and only the closing tag ends it, so quotes and angle brackets are ordinary
characters there.

Returns:

  • (Boolean)


92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 92

def (field, value)
  return false if value.blank?
  return true if (value).any? { |leaf| active_markup?(leaf) }

  type = field[:field_type].to_s
  if ECHOED_TEXTAREA_FIELD_TYPES.include?(type)
    echoed_value(value).downcase.include?('</textarea')
  elsif ECHOED_ATTRIBUTE_FIELD_TYPES.include?(type)
    echoed_value(value).include?('"')
  else
    false
  end
end

#validate_to_save_form(form, fields, errors) ⇒ Object

form validations



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 135

def validate_to_save_form(form, fields, errors)
  # Refuse outright and stop, before any other validation runs.
  #
  # Nothing is stored either way, so there is nothing to gain by continuing -- and continuing means
  # running the rest of this method over input already known to be hostile, including a reCAPTCHA
  # round-trip to an external service.
  #
  # The message names no field and quotes nothing back. The frontend flash partial renders with
  # `raw`, so echoing the refused value there would make the refusal itself an injection sink --
  # the same trap as the admin path.
  if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters))
    errors << t('.invalid_request_val', default: 'That form could not be submitted. Please try again.')
    return false
  end

  if (form, fields)
    errors << form.the_message('invalid_content',
                               t('.invalid_content_val',
                                 default: 'Your message contains characters that are not allowed. ' \
                                          'Please remove any HTML or quotation marks and try again.'))
    return false
  end

  validate = true

  form.fields.each do |f|
    cid = f[:cid].to_sym
    label = f[:label].to_sym
    case f[:field_type].to_s
      when 'text', 'website', 'paragraph', 'textarea', 'email', 'radio', 'checkboxes', 'dropdown', 'file'
        if f[:required].to_s.cama_true? && !fields[cid].present?
          errors << "#{label.to_s.translate}: #{form.the_message('invalid_required', t('.error_validation_val', default: 'This value is required'))}"
          validate = false
        end
        # `to_s` because the submitter chooses whether to send the key at all, and what shape to
        # send it in: `nil.match` and `Hash#match` are both NoMethodError, on a public endpoint.
        if f[:field_type].to_s == 'email'
          unless fields[cid].to_s.match(/@/)
            errors << "#{label.to_s.translate}: #{form.the_message('invalid_email', t('.email_invalid_val', default: 'The e-mail address appears invalid'))}"
            validate = false
          end
        end
      when 'captcha'
        error_message = ->{
          errors << "#{label.to_s.translate}: #{form.the_message('captcha_not_match', t('.captch_error_val', default: 'The entered code is incorrect'))}"
          validate = false
        }

        if form.recaptcha_enabled?
          form.set_captcha_settings!
          error_message.call unless verify_recaptcha
        else
          error_message.call unless cama_captcha_verified?
        end
    end
  end
  validate
end