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
- #active_markup?(string) ⇒ Boolean
-
#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.
-
#convert_form_values(form, fields) ⇒ Object
form values with labels + values to save.
-
#echoed_value(value) ⇒ Object
The exact string the renderer will emit.
- #perform_save_form(form, fields, success, errors) ⇒ Object
- #relevant_field?(field) ⇒ Boolean
-
#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.
-
#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.
-
#unsafe_submitted_field?(field, value) ⇒ Boolean
Two sinks, judged separately because they are different contexts:.
-
#validate_to_save_form(form, fields, errors) ⇒ Object
form validations.
Instance Method Details
#active_markup?(string) ⇒ 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.
73 74 75 76 77 78 |
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 73 def compute_unsafe_submitted?(form, fields) return true if form.blank? return true unless fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters) form.fields.any? { |f| unsafe_submitted_field?(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) = [] 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 << 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) = 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: , fields: fields_data, values: fields, message_body: , form: form}) cama_send_email(form.mail_settings[:to], form.mail_settings[:subject].to_s.translate.cama_replace_codes(fields), {attachments: , content: content, extra_data: {fields: fields_data}}) success << form.('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.('mail_sent_ng', t('.error_form_val', default: 'An error occurred, please try again.')) end end end |
#relevant_field?(field) ⇒ 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 submitted_leaves(value) case value when ActionController::Parameters then submitted_leaves(value.to_unsafe_h) when Hash then value.values.flat_map { |v| submitted_leaves(v) } when Array then value.flat_map { |v| submitted_leaves(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.
65 66 67 68 69 |
# File 'app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb', line 65 def unsafe_submitted?(form, fields) return @_cf_unsafe_submitted unless @_cf_unsafe_submitted.nil? @_cf_unsafe_submitted = compute_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.
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 unsafe_submitted_field?(field, value) return false if value.blank? return true if submitted_leaves(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 unsafe_submitted?(form, fields) errors << form.('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.('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.('invalid_email', t('.email_invalid_val', default: 'The e-mail address appears invalid'))}" validate = false end end when 'captcha' = ->{ errors << "#{label.to_s.translate}: #{form.('captcha_not_match', t('.captch_error_val', default: 'The entered code is incorrect'))}" validate = false } if form.recaptcha_enabled? form.set_captcha_settings! .call unless verify_recaptcha else .call unless cama_captcha_verified? end end end validate end |