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 +4 -4
- data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +157 -12
- data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +21 -22
- data/app/controllers/plugins/cama_contact_form/front_controller.rb +28 -3
- data/app/models/plugins/cama_contact_form/cama_contact_form.rb +21 -11
- data/config/locales/es.yml +3 -1
- data/config/locales/zh-CN.yml +2 -0
- data/lib/cama_contact_form/core_compatibility.rb +34 -0
- data/lib/cama_contact_form/engine.rb +10 -0
- data/lib/cama_contact_form/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1df5cd27281765a9aa2642b6442a5613db070ac0777c32e0726e8efb8f05d48
|
|
4
|
+
data.tar.gz: '09312e91b0deb83c6c3508e8d84228deafbc8ef8ea8f45581a66e0ef36ea8cd6'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 (
|
|
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
|
|
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
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
# running the rest of this method over input already known
|
|
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
|
|
10
|
-
# floor
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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,
|
|
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="<img ...>"` 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
108
|
+
form = self.class.find_by(id: parent_id)
|
|
102
109
|
response_data = the_settings[:fields]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
data/config/locales/es.yml
CHANGED
|
@@ -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.'
|
data/config/locales/zh-CN.yml
CHANGED
|
@@ -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
|
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.
|
|
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.
|
|
92
|
+
rubygems_version: 4.0.19
|
|
92
93
|
specification_version: 4
|
|
93
94
|
summary: Contact Form Plugin for Camaleon CMS
|
|
94
95
|
test_files: []
|