cama_contact_form 0.1.13 → 0.1.14

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0773dea2d0ad03b4c0179fc634372ab5fe9a6111da16fe0c187118475df052f6
4
- data.tar.gz: 7868caf0afb73a4f508855521a1c4a15660a7786dbea9876303096402f198844
3
+ metadata.gz: b1df5cd27281765a9aa2642b6442a5613db070ac0777c32e0726e8efb8f05d48
4
+ data.tar.gz: '09312e91b0deb83c6c3508e8d84228deafbc8ef8ea8f45581a66e0ef36ea8cd6'
5
5
  SHA512:
6
- metadata.gz: 9435100f0afd723a911aa6efee2fc5ed30610b7eea1a5f640eeb45d6e5ff92a7199d4360d599b0ebffcfc279e79fb17dff80135fb4db69e9d88233d356001ea8
7
- data.tar.gz: 563ba96b2539b554971caee757e47ed840ac48918c7564993197095876d3435b74ac86974023caead09ebcb6742f979f86d1ab58e651d87f7b47e134a5036c66
6
+ metadata.gz: 2d129553fbafaa0ed0e647c27985ae4ccf426ca3cded590f895b965cc20d69dc488b2edf1a4a372411cb23896bfc1a9ef6ae5ae707935ab28a39fc57e08052c0
7
+ data.tar.gz: 5c35ee94f4eec03c48e273326aa57884fa185c2a933886bde25bbbbeda97de4b4d12338a388afdb10b37049d9f2ae91c02ae343044be83cd714b97ad1343f7d4
@@ -14,8 +14,13 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
14
14
  # Field types whose submitted value is a list of chosen option labels, joined for the mail summary.
15
15
  MULTI_VALUE_FIELD_TYPES = %w[radio checkboxes].freeze
16
16
 
17
- # Elements that do something rather than say something, wherever they appear.
18
- ACTIVE_ELEMENTS = %w[script style iframe object embed applet frame frameset form input button
17
+ # Elements that do something rather than say something, wherever they appear. `img` sits with the
18
+ # other external-resource loaders (`iframe`, `object`, `embed`): the notification e-mail renders a
19
+ # visitor's value with `raw`, so `<img src="http://attacker/">` there is a tracking beacon that
20
+ # fetches an attacker URL -- leaking that the owner opened the mail, and their IP -- the moment
21
+ # they open it, which no contact message legitimately needs. Ordinary prose with angle brackets
22
+ # (`Fish & Chips <today>`) is an unknown tag, not one of these, so it still passes.
23
+ ACTIVE_ELEMENTS = %w[script style iframe object embed img applet frame frameset form input button
19
24
  link meta base svg math template].freeze
20
25
  ACTIVE_ELEMENT = %r{<\s*/?\s*(?:#{ACTIVE_ELEMENTS.join('|')})\b}i
21
26
  EVENT_HANDLER_IN_TAG = /<[a-zA-Z][^>]*\son[a-zA-Z]+\s*=/im
@@ -23,6 +28,19 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
23
28
  /<[a-zA-Z][^>]*\b(?:href|src|action|formaction|data|poster|srcdoc|background)\s*=\s*
24
29
  ["']?\s*(?:javascript|vbscript|data)\s*:/imx
25
30
 
31
+ # How many submissions one client IP may make to one form before the excess is refused, and the
32
+ # window that count rolls off over. The threshold is the site-wide `contact_form_max_submits` option
33
+ # (it tunes every form on the site at once, not one form); the window is camaleon_cms's own login
34
+ # throttle window, shared as one constant so the two cannot drift.
35
+ SUBMISSION_THROTTLE_WINDOW = CamaleonCms::CaptchaHelper::CAMA_ATTACK_WINDOW
36
+ SUBMISSION_THROTTLE_DEFAULT_MAX = 10
37
+
38
+ # How many files one submission may attach across all of its file fields, before the whole
39
+ # submission is refused. Core bounds each file's size (`filesystem_max_size`); this bounds the
40
+ # count, so the two together bound what one anonymous POST can write to disk and stuff into the
41
+ # notification mail. The site-wide `contact_form_max_files` option overrides it.
42
+ ATTACHMENT_COUNT_DEFAULT_MAX = 5
43
+
26
44
  def perform_save_form(form, fields, success, errors)
27
45
  attachments = []
28
46
  return unless validate_to_save_form(form, fields, errors)
@@ -47,9 +65,14 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
47
65
  fields[f[:cid].to_sym] = file_paths
48
66
  end
49
67
  new_settings = { 'fields' => fields, 'created_at' => Time.now.utc.strftime('%Y-%m-%d %H:%M:%S').to_s }.to_json
50
- form_new = current_site.contact_forms.new(name: "response-#{Time.now.utc}", description: form.description,
68
+ # The random suffix keeps concurrent responses distinct: stamped only to the second, two visitors
69
+ # submitting within the same second parameterized to the same slug, and the site-scoped
70
+ # slug-uniqueness validation refused the second response with the generic error.
71
+ form_new = current_site.contact_forms.new(name: "response-#{Time.now.utc}-#{SecureRandom.hex(4)}",
72
+ description: form.description,
51
73
  settings: new_settings, site_id: form.site_id, parent_id: form.id)
52
74
  if form_new.save
75
+ record_submission(form)
53
76
  fields_data = convert_form_values(form, fields)
54
77
  message_body = form.mail_settings[:body].to_s.translate.cama_replace_codes(fields)
55
78
  content = render_to_string(partial: plugin_view('contact_form/email_content'), layout: false, formats: [:html],
@@ -74,10 +97,10 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
74
97
  end
75
98
  end
76
99
 
77
- # CF-1: the auto-reply ("confirmation e-mail") recipient is whatever the visitor typed into the field
100
+ # The auto-reply ("confirmation e-mail") recipient is whatever the visitor typed into the field
78
101
  # named by `to_answer`, so it is fully attacker-controlled -- unchecked, the feature sends mail from
79
102
  # the site's own From address to anyone. The reply goes to the normalized address, or nowhere.
80
- # Volume across submissions is a separate concern (CF-2, rate limiting).
103
+ # Volume across submissions is a separate concern (rate limiting, below).
81
104
  #
82
105
  # A refused present value is logged -- otherwise the response is indistinguishable from full
83
106
  # success and a lost confirmation is undiagnosable. The line names the form, not the value: the
@@ -89,7 +112,7 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
89
112
  value = fields[form.mail_settings[:to_answer].to_s.gsub(/(\[|\])/, '').to_sym]
90
113
  address = normalized_email_address(value)
91
114
  if address.nil? && value.present?
92
- Rails.logger.warn("cama_contact_form: auto-reply for form #{form.id} skipped, recipient failed validation (CF-1)")
115
+ Rails.logger.warn("cama_contact_form: auto-reply for form #{form.id} skipped, recipient failed validation")
93
116
  end
94
117
  address
95
118
  end
@@ -108,6 +131,73 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
108
131
  address if address.match?(URI::MailTo::EMAIL_REGEXP)
109
132
  end
110
133
 
134
+ # Whether this client IP has already used up its budget for this form in the current window, so
135
+ # `save_form` can refuse the excess before any mail, upload or row is written -- the endpoint is
136
+ # public and, unless the form carries a captcha field, otherwise unthrottled. A read only: only a
137
+ # *stored* submission spends budget (see `record_submission`), so a flood of submissions that fail
138
+ # validation -- or that never solve a captcha -- cannot exhaust the window and lock out a co-NAT
139
+ # visitor whose own submission is valid.
140
+ def submission_over_limit?(form)
141
+ Rails.cache.read(submission_throttle_key(form), raw: true).to_i >= submission_limit
142
+ end
143
+
144
+ # Spend one unit of this IP's per-form budget, called once a row has actually been written so the
145
+ # cap tracks resource-consuming submissions rather than mere attempts.
146
+ #
147
+ # The counter is an ATOMIC Rails.cache increment mirroring camaleon_cms's login throttle. Its helper
148
+ # (cama_captcha_increment_attack) is deliberately not reused: it also mutates session state, which
149
+ # this IP-only throttle must not do on a public, session-light request (and its unit spec drives a
150
+ # bare controller with no session). `raw: true` keeps the value a bare integer so Redis/Memcached
151
+ # INCR is atomic (a harmless no-op on Memory/File stores). Older Memory/File stores (Rails < 7.1)
152
+ # return nil for a missing key instead of seeding it; seed it then, with `unless_exist` so the seed
153
+ # can never overwrite a counter a concurrent request has already advanced.
154
+ #
155
+ # The window is FIXED, not sliding: the TTL is anchored when the key is first written and not
156
+ # refreshed on later increments (ActiveSupport preserves the original `expires_at`, and
157
+ # Redis/Memcached only set a TTL at creation), so the budget resets in full once the window since
158
+ # the first stored submission elapses. The throttle is only as strong as the host's cache: it fails
159
+ # open on a null store, and on a per-process store (Rails' default MemoryStore, or FileStore across
160
+ # hosts) each worker counts on its own, so an effective cap needs a shared store (Redis/Memcached).
161
+ # The key is the client IP as camaleon_cms resolves it (`request.remote_ip`), so it is only as
162
+ # trustworthy as the app's trusted-proxy configuration.
163
+ def record_submission(form)
164
+ key = submission_throttle_key(form)
165
+ counted = Rails.cache.increment(key, 1, expires_in: SUBMISSION_THROTTLE_WINDOW, raw: true)
166
+ return unless counted.nil?
167
+
168
+ Rails.cache.write(key, 1, expires_in: SUBMISSION_THROTTLE_WINDOW, unless_exist: true, raw: true)
169
+ end
170
+
171
+ def submission_throttle_key(form)
172
+ "cama_contact_form_submit:#{current_site.id}:#{request.remote_ip}:#{form.id}"
173
+ end
174
+
175
+ # The per-window budget from `contact_form_max_submits`, as a positive integer.
176
+ def submission_limit
177
+ positive_site_option('contact_form_max_submits', SUBMISSION_THROTTLE_DEFAULT_MAX)
178
+ end
179
+
180
+ # The per-submission attachment budget from `contact_form_max_files`, as a positive integer.
181
+ def attachment_limit
182
+ positive_site_option('contact_form_max_files', ATTACHMENT_COUNT_DEFAULT_MAX)
183
+ end
184
+
185
+ # A limit option, as a positive integer. get_option hands back whatever was stored, and
186
+ # camaleon_cms's set_option runs values through String#to_var -- so a "true"/"false" option is a
187
+ # boolean (and `false.to_i` raises), a cleared one is nil, and a stray "unlimited"/0 would coerce
188
+ # to 0 and refuse every submission (or every attachment) site-wide. Anything that is not a
189
+ # positive integer falls back to the default rather than 500-ing or silently bricking the form; to
190
+ # loosen a limit an operator sets a higher positive integer.
191
+ #
192
+ # A String is parsed in base 10 explicitly: to_var stores only canonical numerals as numbers, so
193
+ # a typed "010" survives as a String, and bare Integer() would read its leading zero as octal --
194
+ # a silently wrong limit. Base 10 reads it as ten, and rejects "0x10" into the fallback.
195
+ def positive_site_option(name, default)
196
+ value = current_site.get_option(name, default)
197
+ parsed = value.is_a?(String) ? Integer(value, 10, exception: false) : Integer(value, exception: false)
198
+ parsed&.positive? ? parsed : default
199
+ end
200
+
111
201
  # A visitor's submission is rejected, not escaped, for the same reason an untrusted author's is:
112
202
  # what is stored and redisplayed then equals what was submitted, so rendering it verbatim adds
113
203
  # nothing.
@@ -185,16 +275,19 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
185
275
 
186
276
  # form validations
187
277
  def validate_to_save_form(form, fields, errors)
188
- # Refuse outright and stop, before any other validation runs.
189
- #
190
- # Nothing is stored either way, so there is nothing to gain by continuing -- and continuing means
191
- # running the rest of this method over input already known to be hostile, including a reCAPTCHA
192
- # round-trip to an external service.
278
+ # One refusal, outright and first, for every shape no real form produces -- a missing form, a
279
+ # non-hash fields, a forged file-field value. Each is a forged request, not a validation error
280
+ # the visitor can act on; nothing is stored either way, so there is nothing to gain by
281
+ # continuing -- and continuing means running the rest of this method over input already known
282
+ # to be hostile, including a reCAPTCHA round-trip to an external service. Rejecting the file
283
+ # shapes is also what keeps forged entries away from the uploader (see
284
+ # malformed_file_submission?, whose walk the earlier disjuncts' short-circuit vouches for).
193
285
  #
194
286
  # The message names no field and quotes nothing back. The frontend flash partial renders with
195
287
  # `raw`, so echoing the refused value there would make the refusal itself an injection sink --
196
288
  # the same trap as the admin path.
197
- if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters))
289
+ if form.blank? || !(fields.is_a?(Hash) || fields.is_a?(ActionController::Parameters)) ||
290
+ malformed_file_submission?(form, fields)
198
291
  errors << t('.invalid_request_val', default: 'That form could not be submitted. Please try again.')
199
292
  return false
200
293
  end
@@ -209,6 +302,25 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
209
302
 
210
303
  validate = true
211
304
 
305
+ # perform_save_form's file loop uploads and persists every entry submitted under a file field,
306
+ # so the entry count is budget the visitor spends -- bounded here, before any upload runs,
307
+ # rather than left to multiply against the per-file size cap. Over the cap the submission is
308
+ # refused whole, with the other validation errors: trimming to the first N would silently drop
309
+ # files the visitor believes they sent, and the file input is `multiple` with no client-side
310
+ # cap, so a legitimate visitor can hit this and needs the actionable message.
311
+ if attachment_count(form, fields) > (max_files = attachment_limit)
312
+ # The %{max} substitution runs on the resolved message so an author-customized
313
+ # `invalid_files_count` can carry the limit too -- `the_message` returns a custom message
314
+ # verbatim, while the i18n default has already interpolated and is left untouched.
315
+ errors << form.the_message('invalid_files_count',
316
+ t('.too_many_files_val',
317
+ max: max_files,
318
+ default: 'Too many files attached (maximum %{max}). ' \
319
+ 'Please remove some files and try again.'))
320
+ .to_s.gsub('%{max}', max_files.to_s)
321
+ validate = false
322
+ end
323
+
212
324
  form.fields.each do |f|
213
325
  cid = f[:cid].to_sym
214
326
  label = f[:label].to_sym
@@ -250,6 +362,39 @@ module Plugins::CamaContactForm::ContactFormControllerConcern
250
362
  validate
251
363
  end
252
364
 
365
+ # Whether any file field carries a value no real form submission produces. The renderer encodes a
366
+ # file field as `fields[cid][]` file parts, and Rack drops an empty-filename part, so the only
367
+ # legitimate shapes are an absent value -- nil, literally, which is why the skip below tests
368
+ # exactly that and not `blank?`: a blank-but-present value (`fields[cid]=`, a JSON `false`) is as
369
+ # forged as any other scalar, and the upload loop's `.to_a` raises on it all the same -- and an
370
+ # array of uploaded files. A bare string, a nested hash, a non-file entry -- each previously
371
+ # raised in the upload loop (`String#to_a`, `original_filename`), an unauthenticated 500.
372
+ #
373
+ # Refusing the whole submission, rather than skipping the forged entries, is load-bearing: a
374
+ # String that survived to cama_tmp_upload would be treated there as a URL to download or a local
375
+ # path to copy, and an anonymous visitor must never steer that.
376
+ def malformed_file_submission?(form, fields)
377
+ form.fields.any? do |f|
378
+ next false unless f[:field_type] == 'file'
379
+
380
+ value = fields[f[:cid].to_sym]
381
+ next false if value.nil?
382
+
383
+ !value.is_a?(Array) || !value.all?(ActionDispatch::Http::UploadedFile)
384
+ end
385
+ end
386
+
387
+ # The number of entries perform_save_form's upload loop would iterate for this submission:
388
+ # everything submitted under a file field. The shape gate has already refused anything that is
389
+ # not an absent value or an array of uploaded files, so this is those arrays' sizes summed --
390
+ # `Array()` keeps the sum total without a shape judgment of its own, the same counting
391
+ # convert_form_values uses.
392
+ def attachment_count(form, fields)
393
+ form.fields.sum do |f|
394
+ f[:field_type] == 'file' ? Array(fields[f[:cid].to_sym]).size : 0
395
+ end
396
+ end
397
+
253
398
  # form values with labels + values to save
254
399
  def convert_form_values(form, fields)
255
400
  values = {}
@@ -6,23 +6,10 @@ class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::Plugin
6
6
  include Plugins::CamaContactForm::MainHelper
7
7
  include Plugins::CamaContactForm::ContactFormControllerConcern
8
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!
9
+ # The markup gate below delegates to CamaleonCms::UnsafeMarkup (camaleon_cms >= 2.9.4); the version
10
+ # floor that guarantees it -- and the front_cache fix the submission throttle needs -- is enforced at
11
+ # boot by CamaContactForm::CoreCompatibility, from the engine initializer, so it holds for every
12
+ # controller in every load mode rather than only where this class body happens to load.
26
13
 
27
14
  before_action :set_form, only: %w[show edit update destroy]
28
15
  add_breadcrumb I18n.t('plugins.cama_contact_form.title', default: 'Contact Form'),
@@ -196,7 +183,7 @@ class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::Plugin
196
183
 
197
184
  # A well-formed translation marker (`<!--:-->`, `<!--:en-->`). `rendered_forms` strips these to
198
185
  # 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
186
+ # CamaleonCms::UnsafeMarkup's own constant rather than re-spelled: the markers this strips and the ones
200
187
  # CamaleonCms::UnsafeMarkup scans around must share one grammar for the gate to stay sound, so they
201
188
  # share one constant.
202
189
  TRANSLATION_MARKER = CamaleonCms::UnsafeMarkup::TRANSLATION_MARKER
@@ -225,13 +212,15 @@ class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::Plugin
225
212
  MAX_OPTIONS_PER_FIELD = 100
226
213
  MAX_GATED_VALUE_BYTES = 64 * 1024
227
214
 
228
- # The messages the form editor offers, plus `invalid_content`, which the gate itself emits.
215
+ # The messages the form editor offers, plus the ones the save-time gates themselves emit
216
+ # (`invalid_content`, `invalid_files_count`) -- a key missing here is not merely un-offered, it is
217
+ # stripped from every editor save, so a message set any other way would be silently erased.
229
218
  #
230
219
  # Permitted rather than taken wholesale. `railscf_message` used to go straight from params into the
231
220
  # gate, which runs a full Loofah parse per leaf -- so the number of parses was chosen by the caller.
232
221
  # Permitting also stops unbounded junk being persisted into `settings.to_json`.
233
222
  MESSAGE_KEYS = %w[mail_sent_ok mail_sent_ng validation_error invalid_required invalid_email
234
- captcha_not_match invalid_content].freeze
223
+ captcha_not_match invalid_content invalid_files_count].freeze
235
224
 
236
225
  private
237
226
 
@@ -486,8 +475,18 @@ class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::Plugin
486
475
  # Returns the name of the first malformed structural value, or nil. Same rule: the key only, never
487
476
  # the value the author submitted.
488
477
  def first_malformed_structural_key(fields)
478
+ seen_cids = {}
489
479
  fields.each do |field|
490
- return 'cid' unless at(field, :cid).to_s.match?(CID_FORMAT)
480
+ cid = at(field, :cid).to_s
481
+ return 'cid' unless cid.match?(CID_FORMAT)
482
+ # A duplicate is as forged as a bad format: the editor generates cids (`c1, c2, ...`) and
483
+ # keys the params by them, so two fields sharing one can only arrive by hand. Stored, they
484
+ # collide everywhere downstream -- the renderer emits colliding input names, and every
485
+ # per-field consumer reads one field's submitted value as the other's (the attachment
486
+ # counter would count it twice; the upload loop 500s on its own second pass).
487
+ return 'cid' if seen_cids.key?(cid)
488
+
489
+ seen_cids[cid] = true
491
490
 
492
491
  type = at(field, :field_type).to_s
493
492
  return 'field type' unless FIELD_TYPES.include?(type)
@@ -547,7 +546,7 @@ class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::Plugin
547
546
  [string]
548
547
  end
549
548
 
550
- # Each rendered form is judged by CamaleonCms::UnsafeMarkup, the core scan-and-reject detector this
549
+ # Each rendered form is judged by CamaleonCms::UnsafeMarkup, camaleon_cms's scan-and-reject detector this
551
550
  # gate is kept in parity with. It parses once and refuses the value when the safe-list scrubber
552
551
  # would remove anything, when a kept attribute smuggles markup (`title="&lt;img ...&gt;"` reaching a
553
552
  # `data-html` sink), when dangerous inline CSS survives, or when the value carries one of the
@@ -8,7 +8,11 @@ class Plugins::CamaContactForm::FrontController < CamaleonCms::Apps::PluginsFron
8
8
  # here add your custom functions
9
9
  def save_form
10
10
  flash[:contact_form] = {}
11
- @form = current_site.contact_forms.find_by(id: params[:id])
11
+ # `parent_id: nil` restricts the lookup to authored forms. `contact_forms` also holds every stored
12
+ # response (a child row, `parent_id` set), and a response's `fields` is empty, so it sails through
13
+ # validation -- submitting a response's id would write a fresh child row and open it its own throttle
14
+ # budget, so the flood cap could be lapped by walking the (sequential, guessable) response ids.
15
+ @form = current_site.contact_forms.find_by(id: params[:id], parent_id: nil)
12
16
  fields = params[:fields]
13
17
  errors = []
14
18
  success = []
@@ -16,9 +20,22 @@ class Plugins::CamaContactForm::FrontController < CamaleonCms::Apps::PluginsFron
16
20
  args = { form: @form, values: fields, flag: true }
17
21
  hooks_run('contact_form_before_submit', args)
18
22
  if args[:flag]
19
- perform_save_form(@form, fields, success, errors)
23
+ # Refuse a flood before it can mail, upload or write a row. Keyed on @form, so a bogus id
24
+ # (which perform_save_form rejects anyway, cheaply) is left to it rather than throttled against
25
+ # no form.
26
+ if @form.present? && submission_over_limit?(@form)
27
+ errors << t('.too_many_requests',
28
+ default: 'Too many submissions from your network. Please wait a few minutes and try again.')
29
+ else
30
+ perform_save_form(@form, fields, success, errors)
31
+ end
20
32
  if success.present?
21
33
  flash[:contact_form][:notice] = success.join('<br>')
34
+ # Success and errors coexist on exactly one path: the submission stored and mailed, but a
35
+ # file failed its upload (over core's size cap, or refused by the content scan). Showing
36
+ # only the notice told the visitor everything arrived when a file they sent was dropped --
37
+ # the errors ride along so the success never silently trims the submission.
38
+ flash[:contact_form][:error] = errors.join('<br>') if errors.present?
22
39
  else
23
40
  flash[:contact_form][:error] = errors.join('<br>')
24
41
  # Redisplaying the submission is the only reason it ever reaches the page, so a submission
@@ -34,8 +51,16 @@ class Plugins::CamaContactForm::FrontController < CamaleonCms::Apps::PluginsFron
34
51
  # `@form` is whatever `find_by_id` returned, which is nil for a deleted or bogus id -- a
35
52
  # POST anyone can make without credentials. `unsafe_submitted?` fails closed on that, so the
36
53
  # guard below is belt and braces for the `@form.id` read on the line after it.
54
+ # Uploads are dropped from the stash whether they arrive bare or -- the only shape the
55
+ # renderer produces -- as an array. They buy nothing (a file input always redisplays with
56
+ # value=""), and they cost the session: each file serializes as junk bytes into the cookie
57
+ # store, so a refused submission with enough attachments overflowed the 4KB cookie into a
58
+ # 500 -- on the over-cap refusal, for exactly the visitors the refusal message is for.
37
59
  if @form.present? && fields.respond_to?(:delete_if) && !unsafe_submitted?(@form, fields)
38
- flash[:values] = fields.delete_if { |_k, v| v.instance_of?(::ActionDispatch::Http::UploadedFile) }
60
+ flash[:values] = fields.delete_if do |_k, v|
61
+ v.instance_of?(::ActionDispatch::Http::UploadedFile) ||
62
+ (v.is_a?(Array) && v.any?(::ActionDispatch::Http::UploadedFile))
63
+ end
39
64
  flash[:values_form_id] = @form.id
40
65
  end
41
66
  end
@@ -10,10 +10,10 @@ class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
10
10
 
11
11
  # A self-referential parent/child: a form and its stored response rows. There is no reciprocal
12
12
  # `belongs_to :parent` to point `inverse_of` at, so the cop is disabled for this association only.
13
- # rubocop:disable Rails/InverseOf
13
+ # rubocop:disable-next Rails/InverseOf
14
14
  has_many :responses, class_name: 'Plugins::CamaContactForm::CamaContactForm', foreign_key: :parent_id,
15
15
  dependent: :destroy
16
- # rubocop:enable Rails/InverseOf
16
+
17
17
  validates :name, presence: true
18
18
  validates :slug, uniqueness: { scope: :site_id }
19
19
 
@@ -95,18 +95,28 @@ class Plugins::CamaContactForm::CamaContactForm < ActiveRecord::Base
95
95
  self.settings = {}.to_json if settings.blank?
96
96
  end
97
97
 
98
+ # Deletes the files this response uploaded, on destroy. Those files live directly in the site's
99
+ # `public/contact_form/<site_id>` directory, and this is confined to delete only from there: each
100
+ # stored path is reduced to its basename and rebuilt inside that root, so a path that is absolute,
101
+ # climbs out with `../`, or is otherwise hostile -- as an untrusted import can leave in a
102
+ # response's settings -- can at most name a file already sitting in the root, never one outside
103
+ # it. Missing parent, non-hash settings and non-string entries are tolerated rather than raising
104
+ # on a destroy any `:manage, :plugins` holder can trigger.
98
105
  def delete_uploaded_files
99
106
  return if parent_id.nil?
100
107
 
101
- form = self.class.find_by id: parent_id
108
+ form = self.class.find_by(id: parent_id)
102
109
  response_data = the_settings[:fields]
103
- file_cids = form.fields
104
- .select { |f| f[:field_type] == 'file' }
105
- .map { |f| f[:cid].to_sym }
106
-
107
- file_cids
108
- .flat_map { |cid| response_data[cid] }
109
- .map { |file| file.sub Rails.application.routes.url_helpers.cama_root_url, Rails.public_path.to_s }
110
- .each { |file| FileUtils.rm_f file }
110
+ return if form.nil? || !response_data.is_a?(Hash)
111
+
112
+ media_root = Rails.public_path.join('contact_form', site_id.to_s).to_s
113
+ form.fields
114
+ .select { |f| f[:field_type] == 'file' }
115
+ .flat_map { |f| response_data[f[:cid].to_sym] }
116
+ .compact
117
+ .each do |file|
118
+ target = File.expand_path(File.basename(file.to_s), media_root)
119
+ FileUtils.rm_f(target) if File.dirname(target) == media_root
120
+ end
111
121
  end
112
122
  end
@@ -107,4 +107,6 @@ es:
107
107
  success_form_val: 'Su mensaje a sido enviado satisfactoriamente. Muchas gracias!'
108
108
  error_form_val: 'A ocurrido un error, por favor intente de nuevo'
109
109
  invalid_content_val: 'Su mensaje contiene caracteres que no están permitidos. Por favor, elimine cualquier HTML o comillas dobles e inténtelo de nuevo.'
110
- invalid_request_val: 'No se pudo enviar ese formulario. Por favor, inténtelo de nuevo.'
110
+ invalid_request_val: 'No se pudo enviar ese formulario. Por favor, inténtelo de nuevo.'
111
+ too_many_requests: 'Demasiados envíos desde tu red. Espera unos minutos e inténtalo de nuevo.'
112
+ too_many_files_val: 'Demasiados archivos adjuntos (máximo %{max}). Elimina algunos archivos e inténtalo de nuevo.'
@@ -106,3 +106,5 @@ zh-CN:
106
106
  error_form_val: '引发了一个错误,请重试!'
107
107
  invalid_content_val: '您的消息包含不允许使用的字符。请删除所有 HTML 或双引号后重试。'
108
108
  invalid_request_val: '无法提交该表单。请重试。'
109
+ too_many_requests: '来自您网络的提交次数过多。请稍等几分钟后重试。'
110
+ too_many_files_val: '附件文件过多(最多 %{max} 个)。请删除部分文件后重试。'
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cama_contact_form/version'
4
+
5
+ module CamaContactForm
6
+ # camaleon_cms version gate. The plugin needs a core new enough to (a) expose
7
+ # `CamaleonCms::UnsafeMarkup` (the admin markup gate delegates to it) and (b) not wipe `Rails.cache`
8
+ # on every frontend POST -- camaleon_cms before 2.9.4 did, through the bundled front_cache plugin,
9
+ # which left the submission throttle counter erased each submission and unable to ever trigger.
10
+ #
11
+ # The floor is not expressible as a gemspec dependency (camaleon_cms depends on this gem, so a
12
+ # reverse pin would be circular), and older camaleon_cms releases pin `cama_contact_form` wide
13
+ # enough to resolve this release. So the plugin verifies the version itself, at boot, from the
14
+ # engine initializer -- a seat every process passes through before serving, unlike a controller
15
+ # class body, which under lazy loading may never load before FrontController#save_form is served.
16
+ # It fails fast with an actionable error rather than a broken gate or a silently dead throttle at
17
+ # the first untrusted request.
18
+ module CoreCompatibility
19
+ MINIMUM_CORE_VERSION = '2.9.4'
20
+
21
+ module_function
22
+
23
+ def compatible_core?
24
+ defined?(CamaleonCms::VERSION) &&
25
+ Gem::Version.new(CamaleonCms::VERSION) >= Gem::Version.new(MINIMUM_CORE_VERSION)
26
+ end
27
+
28
+ def ensure_compatible_core!
29
+ return if compatible_core?
30
+
31
+ raise "cama_contact_form #{CamaContactForm::VERSION} requires camaleon_cms >= #{MINIMUM_CORE_VERSION}."
32
+ end
33
+ end
34
+ end
@@ -1,9 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cama_contact_form/core_compatibility'
4
+
3
5
  # Top-level namespace for the cama_contact_form gem.
4
6
  module CamaContactForm
5
7
  # Rails engine that registers the plugin (and its `forms` shortcode) with a Camaleon CMS host.
6
8
  class Engine < ::Rails::Engine
9
+ # Fail fast at boot on a camaleon_cms too old for the markup gate or the submission throttle to
10
+ # work (see CoreCompatibility). Seated here, not in a controller class body, so the check runs in
11
+ # every process -- including a lazy-loaded one that would otherwise serve save_form before the
12
+ # admin controller (which carries the other half of the requirement) ever loads.
13
+ initializer 'cama_contact_form.ensure_compatible_core' do
14
+ CamaContactForm::CoreCompatibility.ensure_compatible_core!
15
+ end
16
+
7
17
  # Declare this plugin's shortcode name to Camaleon's boot-time shortcode registry so the CMS
8
18
  # save-time `content_shortcodes` gate can detect and gate it. Detection needs the name at boot
9
19
  # because the per-request shortcode list is empty at an admin save; the render-time handler stays
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CamaContactForm
4
- VERSION = '0.1.13'
4
+ VERSION = '0.1.14'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cama_contact_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.13
4
+ version: 0.1.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Owen Peredo
@@ -67,6 +67,7 @@ files:
67
67
  - config/routes.rb
68
68
  - db/migrate/20160517143441_create_db_structure.rb
69
69
  - lib/cama_contact_form.rb
70
+ - lib/cama_contact_form/core_compatibility.rb
70
71
  - lib/cama_contact_form/engine.rb
71
72
  - lib/cama_contact_form/version.rb
72
73
  - lib/tasks/cama_contact_form_tasks.rake
@@ -88,7 +89,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
88
89
  - !ruby/object:Gem::Version
89
90
  version: '0'
90
91
  requirements: []
91
- rubygems_version: 4.0.17
92
+ rubygems_version: 4.0.19
92
93
  specification_version: 4
93
94
  summary: Contact Form Plugin for Camaleon CMS
94
95
  test_files: []