cama_contact_form 0.1.0 → 0.1.13
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/README.md +203 -0
- data/Rakefile +6 -21
- data/app/assets/javascripts/plugins/cama_contact_form/admin_editor.js +4 -4
- data/app/controllers/concerns/plugins/cama_contact_form/contact_form_controller_concern.rb +240 -65
- data/app/controllers/plugins/cama_contact_form/admin_forms_controller.rb +614 -42
- data/app/controllers/plugins/cama_contact_form/front_controller.rb +29 -5
- data/app/helpers/plugins/cama_contact_form/main_helper.rb +217 -103
- data/app/models/plugins/cama_contact_form/cama_contact_form.rb +60 -25
- data/app/views/plugins/cama_contact_form/admin_forms/_item_field.html.erb +8 -1
- data/app/views/plugins/cama_contact_form/forms_shorcode.html.erb +7 -3
- data/config/camaleon_plugin.json +2 -2
- data/config/initializers/custom_models.rb +5 -2
- data/config/locales/es.yml +8 -1
- data/config/locales/zh-CN.yml +7 -0
- data/config/routes.rb +18 -17
- data/lib/cama_contact_form/engine.rb +12 -0
- data/lib/cama_contact_form/version.rb +3 -1
- data/lib/cama_contact_form.rb +5 -5
- data/lib/tasks/cama_contact_form_tasks.rake +2 -0
- metadata +7 -97
- data/test/cama_contact_form_test.rb +0 -7
- data/test/dummy/README.rdoc +0 -28
- data/test/dummy/Rakefile +0 -6
- data/test/dummy/app/assets/javascripts/application.js +0 -13
- data/test/dummy/app/assets/stylesheets/application.css +0 -15
- data/test/dummy/app/controllers/application_controller.rb +0 -5
- data/test/dummy/app/helpers/application_helper.rb +0 -2
- data/test/dummy/app/views/layouts/application.html.erb +0 -14
- data/test/dummy/bin/bundle +0 -3
- data/test/dummy/bin/rails +0 -4
- data/test/dummy/bin/rake +0 -4
- data/test/dummy/bin/setup +0 -29
- data/test/dummy/config/application.rb +0 -26
- data/test/dummy/config/boot.rb +0 -5
- data/test/dummy/config/database.yml +0 -25
- data/test/dummy/config/environment.rb +0 -5
- data/test/dummy/config/environments/development.rb +0 -41
- data/test/dummy/config/environments/production.rb +0 -79
- data/test/dummy/config/environments/test.rb +0 -42
- data/test/dummy/config/initializers/assets.rb +0 -11
- data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/test/dummy/config/initializers/cookies_serializer.rb +0 -3
- data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/test/dummy/config/initializers/inflections.rb +0 -16
- data/test/dummy/config/initializers/mime_types.rb +0 -4
- data/test/dummy/config/initializers/session_store.rb +0 -3
- data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/test/dummy/config/locales/en.yml +0 -23
- data/test/dummy/config/routes.rb +0 -56
- data/test/dummy/config/secrets.yml +0 -22
- data/test/dummy/config.ru +0 -4
- data/test/dummy/public/404.html +0 -67
- data/test/dummy/public/422.html +0 -67
- data/test/dummy/public/500.html +0 -66
- data/test/dummy/public/favicon.ico +0 -0
- data/test/integration/navigation_test.rb +0 -8
- data/test/test_helper.rb +0 -20
|
@@ -1,84 +1,656 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Admin CRUD for a site's contact forms; refuses (never rewrites) authored markup an untrusted role
|
|
4
|
+
# is not permitted to store.
|
|
1
5
|
class Plugins::CamaContactForm::AdminFormsController < CamaleonCms::Apps::PluginsAdminController
|
|
2
6
|
include Plugins::CamaContactForm::MainHelper
|
|
3
7
|
include Plugins::CamaContactForm::ContactFormControllerConcern
|
|
4
|
-
|
|
5
|
-
|
|
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!
|
|
26
|
+
|
|
27
|
+
before_action :set_form, only: %w[show edit update destroy]
|
|
28
|
+
add_breadcrumb I18n.t('plugins.cama_contact_form.title', default: 'Contact Form'),
|
|
29
|
+
:admin_plugins_cama_contact_form_admin_forms_path
|
|
6
30
|
|
|
7
31
|
def index
|
|
8
|
-
@forms = current_site.contact_forms.where(
|
|
9
|
-
@forms = @forms.paginate(:
|
|
32
|
+
@forms = current_site.contact_forms.where(parent_id: nil).all
|
|
33
|
+
@forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
|
|
10
34
|
end
|
|
11
35
|
|
|
36
|
+
def show; end
|
|
37
|
+
|
|
12
38
|
def edit
|
|
13
|
-
add_breadcrumb I18n.t(
|
|
14
|
-
render
|
|
39
|
+
add_breadcrumb I18n.t('plugins.cama_contact_form.edit_view', default: 'Edit contact form')
|
|
40
|
+
render 'edit'
|
|
15
41
|
end
|
|
16
42
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
v[:field_options][:options] = v[:field_options][:options].values if v[:field_options][:options].present?
|
|
23
|
-
fields << v
|
|
24
|
-
}
|
|
25
|
-
@form.update({settings: settings.to_json, value: {fields: fields}.to_json})
|
|
26
|
-
flash[:notice] = t('.updated_success', default: 'Updated successfully')
|
|
43
|
+
def create
|
|
44
|
+
@form = current_site.contact_forms.new(params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name,
|
|
45
|
+
:slug))
|
|
46
|
+
if @form.save
|
|
47
|
+
flash[:notice] = t('.created', default: 'Created successfully').to_s
|
|
27
48
|
redirect_to action: :edit, id: @form.id
|
|
28
49
|
else
|
|
29
|
-
|
|
50
|
+
flash[:error] = @form.errors.full_messages.join(', ')
|
|
51
|
+
redirect_to action: :index
|
|
30
52
|
end
|
|
31
53
|
end
|
|
32
54
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
def update
|
|
56
|
+
form_params = params.require(:plugins_cama_contact_form_cama_contact_form).permit(:name, :slug)
|
|
57
|
+
settings = { 'railscf_mail' => params[:railscf_mail],
|
|
58
|
+
'railscf_message' => permitted_messages,
|
|
59
|
+
'railscf_form_button' => params[:railscf_form_button],
|
|
60
|
+
recaptcha_site_key: params[:recaptcha_site_key],
|
|
61
|
+
recaptcha_secret_key: params[:recaptcha_secret_key] }
|
|
62
|
+
|
|
63
|
+
# The shape of `params` is chosen by the client, not by the form editor, and every check below
|
|
64
|
+
# indexes into it. Verified first, for everyone, so that nothing downstream -- here or in the
|
|
65
|
+
# renderer -- has to cope with a String where it expected a Hash.
|
|
66
|
+
if (malformed = first_malformed_shape_key(settings))
|
|
67
|
+
return reject_save(t('.malformed_structure', field: malformed,
|
|
68
|
+
default: 'A field has a malformed %{field}. Nothing was saved.'))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
fields = submitted_fields
|
|
72
|
+
|
|
73
|
+
# Checked before the record is touched, so a rejected save leaves the form exactly as it was
|
|
74
|
+
# rather than persisting the name and slug and dropping everything else.
|
|
75
|
+
if (malformed = first_malformed_structural_key(fields))
|
|
76
|
+
return reject_save(t('.malformed_structure', field: malformed,
|
|
77
|
+
default: 'A field has a malformed %{field}. Nothing was saved.'))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if !trusted_for_unfiltered_html? && (rejected = first_unpermitted_html_key(settings, fields))
|
|
81
|
+
return reject_save(rejection_message(*rejected))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if @form.update(form_params)
|
|
85
|
+
@form.update({ settings: settings.to_json, value: { fields: fields }.to_json })
|
|
86
|
+
flash[:notice] = t('.updated_success', default: 'Updated successfully')
|
|
37
87
|
redirect_to action: :edit, id: @form.id
|
|
38
88
|
else
|
|
39
|
-
|
|
40
|
-
redirect_to action: :index
|
|
89
|
+
edit
|
|
41
90
|
end
|
|
42
91
|
end
|
|
43
92
|
|
|
44
93
|
def destroy
|
|
45
|
-
flash[:notice] =
|
|
94
|
+
flash[:notice] = t('.deleted', default: 'Destroyed successfully').to_s if @form.destroy
|
|
46
95
|
redirect_to action: :index
|
|
47
96
|
end
|
|
48
97
|
|
|
49
98
|
def responses
|
|
50
|
-
add_breadcrumb I18n.t(
|
|
51
|
-
@form = current_site.contact_forms.where({id: params[:admin_form_id]}).first
|
|
99
|
+
add_breadcrumb I18n.t('plugins.cama_contact_form.list_responses', default: 'Contact form records')
|
|
100
|
+
@form = current_site.contact_forms.where({ id: params[:admin_form_id] }).first
|
|
52
101
|
values = JSON.parse(@form.value).to_sym
|
|
53
|
-
@op_fields = values[:fields].select{ |field| relevant_field? field }
|
|
54
|
-
@forms = current_site.contact_forms.where({parent_id: @form.id})
|
|
55
|
-
@forms = @forms.paginate(:
|
|
102
|
+
@op_fields = values[:fields].select { |field| relevant_field? field }
|
|
103
|
+
@forms = current_site.contact_forms.where({ parent_id: @form.id })
|
|
104
|
+
@forms = @forms.paginate(page: params[:page], per_page: current_site.admin_per_page)
|
|
56
105
|
end
|
|
57
106
|
|
|
58
107
|
def del_response
|
|
59
|
-
response = current_site.contact_forms.
|
|
108
|
+
response = current_site.contact_forms.find_by(id: params[:response_id])
|
|
60
109
|
if response.present? && response.destroy
|
|
61
|
-
flash[:notice] =
|
|
110
|
+
flash[:notice] = t('.actions.msg_deleted', default: 'The response has been deleted').to_s
|
|
62
111
|
end
|
|
63
112
|
redirect_to action: :responses
|
|
64
113
|
end
|
|
65
114
|
|
|
66
|
-
def manual
|
|
67
|
-
|
|
68
|
-
end
|
|
115
|
+
def manual; end
|
|
69
116
|
|
|
70
117
|
def item_field
|
|
71
|
-
render partial: 'item_field', locals:{ field_type: params[:kind], cid: params[:cid] }
|
|
118
|
+
render partial: 'item_field', locals: { field_type: params[:kind], cid: params[:cid] }
|
|
72
119
|
end
|
|
73
120
|
|
|
74
|
-
# here
|
|
121
|
+
# Values the author writes that reach the page verbatim. Nothing here is rewritten on save: an
|
|
122
|
+
# author either holds :manage, :contact_form_unfiltered_html and their content is stored exactly as
|
|
123
|
+
# written, or the save is rejected and they are told which field to fix.
|
|
124
|
+
#
|
|
125
|
+
# Two kinds of position, because they fail differently:
|
|
126
|
+
#
|
|
127
|
+
# Markup positions reach the page as element content. Unsafe means the value is not markup this
|
|
128
|
+
# role may write, or is markup a browser would read differently from the way it was parsed here.
|
|
129
|
+
#
|
|
130
|
+
# Attribute positions are interpolated by the renderer inside a double-quoted HTML attribute.
|
|
131
|
+
# Unsafe means only that the value contains a double quote. Angle brackets, ampersands and
|
|
132
|
+
# apostrophes are harmless there, so they are allowed.
|
|
133
|
+
#
|
|
134
|
+
# Which position a value lands in is fixed rather than inferred, because the field `template` is
|
|
135
|
+
# authored too. `placeholder_in_tag?` refuses a template that puts `[ci]`, `[label ci]` or
|
|
136
|
+
# `[descr ci]` inside a tag, so every substituted value is element content and every attribute in
|
|
137
|
+
# the emitted markup is one the renderer wrote with double quotes. That rule binds everyone, not
|
|
138
|
+
# just untrusted authors: without it a template written by a trusted author decides the context of
|
|
139
|
+
# a value written by an untrusted one, and the table below stops being true.
|
|
140
|
+
MARKUP_MAIL_KEYS = %w[previous_html after_html body body_answer subject subject_answer].freeze
|
|
141
|
+
MARKUP_FIELD_KEYS = %w[label].freeze
|
|
142
|
+
MARKUP_FIELD_OPTION_KEYS = %w[template description].freeze
|
|
143
|
+
|
|
144
|
+
# `label` is also emitted into `name="..."` by the "other" input of a radio/checkbox group.
|
|
145
|
+
ATTRIBUTE_FIELD_KEYS = %w[label].freeze
|
|
146
|
+
ATTRIBUTE_FIELD_OPTION_KEYS = %w[field_class].freeze
|
|
147
|
+
|
|
148
|
+
# Built with a symbol key by #update, unlike the string-keyed settings around it. It reaches the
|
|
149
|
+
# page as `data-sitekey="..."`, which the recaptcha gem interpolates without escaping.
|
|
150
|
+
ATTRIBUTE_SETTING_KEYS = %i[recaptcha_site_key].freeze
|
|
151
|
+
|
|
152
|
+
# Mail values the code indexes or interpolates. Held to a scalar for everyone: `to_answer` reaches
|
|
153
|
+
# `String#gsub` in the front controller and `subject` reaches the mailer, both after a response row
|
|
154
|
+
# has already been persisted.
|
|
155
|
+
MAIL_SCALAR_KEYS = %w[to subject body to_answer subject_answer body_answer
|
|
156
|
+
previous_html after_html].freeze
|
|
157
|
+
|
|
158
|
+
# The field types whose default_value is redisplayed as textarea content rather than in a value
|
|
159
|
+
# attribute, and the ones that carry a list of options. Kept in step with the renderer's `case`.
|
|
160
|
+
TEXTAREA_FIELD_TYPES = %w[paragraph textarea].freeze
|
|
161
|
+
OPTION_FIELD_TYPES = %w[radio checkboxes dropdown select].freeze
|
|
162
|
+
|
|
163
|
+
# An attribute whose *name* is an event handler runs script whatever its value is. It breaks out of
|
|
164
|
+
# nothing, so neither the name-shape check nor the double-quote check below can see it: `onfocus`
|
|
165
|
+
# is a perfectly well-formed attribute name carrying a perfectly quote-free value.
|
|
166
|
+
#
|
|
167
|
+
# A blunt `on` prefix rather than a list of known handler names, which is what HTML sanitizers use:
|
|
168
|
+
# the handler set grows with the platform, and a list that has fallen behind fails open.
|
|
169
|
+
EVENT_HANDLER_ATTR_NAME = /\Aon/i
|
|
170
|
+
HTML_ATTR_NAME = /\A[a-zA-Z_:][-a-zA-Z0-9_:.]*\z/
|
|
171
|
+
|
|
172
|
+
# An attribute name is not enough on its own: `formaction` on a submit button, or `href` on a link,
|
|
173
|
+
# executes whatever scheme its value names. These carry a URL, so their value gets a scheme check;
|
|
174
|
+
# `style` is refused outright, because an untrusted author has no need of inline CSS and
|
|
175
|
+
# `position:fixed` over the viewport is a UI-redressing primitive.
|
|
176
|
+
URL_BEARING_ATTR_NAMES = %w[href src action formaction data poster srcdoc xlink:href
|
|
177
|
+
background dynsrc lowsrc].freeze
|
|
178
|
+
REFUSED_ATTR_NAMES = %w[style].freeze
|
|
179
|
+
URL_IGNORABLE_CHARS = /[[:space:]\u0000-\u001F\u007F]/
|
|
180
|
+
DANGEROUS_URL_SCHEME = /\A(?:javascript|vbscript|data):/i
|
|
181
|
+
|
|
182
|
+
# The safe list CamaleonCms::UnsafeMarkup scrubs these positions against: Rails' own, widened with
|
|
183
|
+
# the layout elements these positions legitimately carry. Rails' list is tuned for prose and omits
|
|
184
|
+
# `label`, `section`, `fieldset` and the table elements -- which is why the plugin's own default
|
|
185
|
+
# template was refused by an earlier implementation.
|
|
186
|
+
#
|
|
187
|
+
# `rel` and `target` are deliberately NOT added. Rails omits them on purpose: `rel="opener"` is the
|
|
188
|
+
# explicit opt-back-in to the `window.opener` handle that browsers disable for `target="_blank"`,
|
|
189
|
+
# and it hands an untrusted author the ability to repoint the visitor's original tab.
|
|
190
|
+
MARKUP_TAGS = (Rails::HTML5::SafeListSanitizer.allowed_tags +
|
|
191
|
+
%w[label section article header footer main aside nav figure figcaption
|
|
192
|
+
fieldset legend table caption colgroup col thead tbody tfoot tr td th
|
|
193
|
+
time mark wbr picture source]).freeze
|
|
194
|
+
MARKUP_ATTRS = (Rails::HTML5::SafeListSanitizer.allowed_attributes +
|
|
195
|
+
%w[id for colspan rowspan span role tabindex hidden]).freeze
|
|
196
|
+
|
|
197
|
+
# A well-formed translation marker (`<!--:-->`, `<!--:en-->`). `rendered_forms` strips these to
|
|
198
|
+
# 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
|
|
200
|
+
# CamaleonCms::UnsafeMarkup scans around must share one grammar for the gate to stay sound, so they
|
|
201
|
+
# share one constant.
|
|
202
|
+
TRANSLATION_MARKER = CamaleonCms::UnsafeMarkup::TRANSLATION_MARKER
|
|
203
|
+
|
|
204
|
+
PLACEHOLDER = /\[(?:ci|label ci|descr ci)\]/
|
|
205
|
+
|
|
206
|
+
# One whole tag, quoted attribute values included, so a `>` written inside an attribute does not
|
|
207
|
+
# look like the end of the tag. The closing quote is optional so an unterminated one runs to the
|
|
208
|
+
# end of the value rather than ending the match early.
|
|
209
|
+
TAG_SPAN = %r{<[a-zA-Z/!?][^"'>]*(?:(?:"[^"]*"?|'[^']*'?)[^"'>]*)*>?}
|
|
210
|
+
|
|
211
|
+
# Structural values, as opposed to authored content: the form builder generates them and nobody
|
|
212
|
+
# types markup into them. They are checked against an allowlist for *everyone*, including an author
|
|
213
|
+
# holding the unfiltered-HTML grant -- a field whose type is `text" onfocus="x` is not a feature
|
|
214
|
+
# anyone wants, it is a corrupt record. Constraining them here is what lets the renderer emit them
|
|
215
|
+
# verbatim alongside everything else.
|
|
216
|
+
# `select` is the legacy spelling of `dropdown`; a form stored under an older version carries it.
|
|
217
|
+
FIELD_TYPES = %w[text paragraph textarea website email radio checkboxes dropdown select captcha file
|
|
218
|
+
submit button reset_button].freeze
|
|
219
|
+
CID_FORMAT = /\A[a-zA-Z0-9_-]+\z/
|
|
220
|
+
|
|
221
|
+
# Every gated value costs a parse, and both the number of values and their size are chosen by the
|
|
222
|
+
# caller. Without these an account holding nothing but :manage, :plugins can drive several thousand
|
|
223
|
+
# Loofah parses, or one multi-megabyte parse, from a single request.
|
|
224
|
+
MAX_FIELDS = 200
|
|
225
|
+
MAX_OPTIONS_PER_FIELD = 100
|
|
226
|
+
MAX_GATED_VALUE_BYTES = 64 * 1024
|
|
227
|
+
|
|
228
|
+
# The messages the form editor offers, plus `invalid_content`, which the gate itself emits.
|
|
229
|
+
#
|
|
230
|
+
# Permitted rather than taken wholesale. `railscf_message` used to go straight from params into the
|
|
231
|
+
# gate, which runs a full Loofah parse per leaf -- so the number of parses was chosen by the caller.
|
|
232
|
+
# Permitting also stops unbounded junk being persisted into `settings.to_json`.
|
|
233
|
+
MESSAGE_KEYS = %w[mail_sent_ok mail_sent_ng validation_error invalid_required invalid_email
|
|
234
|
+
captcha_not_match invalid_content].freeze
|
|
235
|
+
|
|
75
236
|
private
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
237
|
+
|
|
238
|
+
# Mirrors CamaleonCms::Post#trusted_for_unfiltered_html?: read the acting user and site from
|
|
239
|
+
# CurrentRequest and fail closed when either is missing, so saves from background jobs, rake tasks
|
|
240
|
+
# or the console are treated as untrusted. The site is guarded as well because Ability#initialize
|
|
241
|
+
# dereferences it for non-admin users and would otherwise raise mid-save.
|
|
242
|
+
def trusted_for_unfiltered_html?
|
|
243
|
+
user = CurrentRequest.user
|
|
244
|
+
site = CurrentRequest.site
|
|
245
|
+
return false if user.blank? || site.blank?
|
|
246
|
+
|
|
247
|
+
CamaleonCms::Ability.new(user, site).can?(:manage, :contact_form_unfiltered_html)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Normalizes the submitted fields into the list that is stored. Shape is already known good --
|
|
251
|
+
# `first_malformed_shape_key` refuses a non-hash field or field_options rather than dropping it,
|
|
252
|
+
# so nothing here silently discards what the author typed.
|
|
253
|
+
def submitted_fields
|
|
254
|
+
raw = params[:fields]
|
|
255
|
+
return [] unless hashish?(raw)
|
|
256
|
+
|
|
257
|
+
raw.each_value.map do |v|
|
|
258
|
+
options = at(v, :field_options)
|
|
259
|
+
v[:field_options] = options = ActionController::Parameters.new if options.blank?
|
|
260
|
+
options[:options] = options[:options].values if hashish?(options[:options])
|
|
261
|
+
v
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Returns `[name, rule]` for the first value an untrusted author may not save, or nil.
|
|
266
|
+
#
|
|
267
|
+
# `name` is only ever a key from the lists above -- never the value, and never the field's label.
|
|
268
|
+
# The message built from it is rendered with `raw` by Camaleon's admin flash partial, so echoing
|
|
269
|
+
# back the content that was just rejected would make the rejection itself an injection sink.
|
|
270
|
+
#
|
|
271
|
+
# `rule` names which test refused it, so the message can say what to remove. Telling an author who
|
|
272
|
+
# typed `btn "primary"` into Custom Class that it "contains HTML" sends them looking for markup
|
|
273
|
+
# that is not there, and points them at a permission that would not have applied.
|
|
274
|
+
#
|
|
275
|
+
# Stops at the first offender rather than collecting them all: nothing is saved either way.
|
|
276
|
+
def first_unpermitted_html_key(settings, fields)
|
|
277
|
+
mail = settings['railscf_mail']
|
|
278
|
+
MARKUP_MAIL_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(mail, k)) }
|
|
279
|
+
return ['submit button label', :markup] if unsafe_markup?(at(settings['railscf_form_button'], 'name_button'))
|
|
280
|
+
return ['response message', :markup] if unsafe_messages?(settings['railscf_message'])
|
|
281
|
+
|
|
282
|
+
ATTRIBUTE_SETTING_KEYS.each { |k| return [k.to_s, :attribute] if unsafe_attribute?(settings[k]) }
|
|
283
|
+
|
|
284
|
+
fields.each do |field|
|
|
285
|
+
MARKUP_FIELD_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(field, k)) }
|
|
286
|
+
ATTRIBUTE_FIELD_KEYS.each { |k| return [k, :attribute] if unsafe_attribute?(at(field, k)) }
|
|
287
|
+
if (rule = unsafe_default_value_rule(field))
|
|
288
|
+
return ['default_value', rule]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
options = at(field, :field_options)
|
|
292
|
+
next if options.blank?
|
|
293
|
+
|
|
294
|
+
MARKUP_FIELD_OPTION_KEYS.each { |k| return [k, :markup] if unsafe_markup?(at(options, k)) }
|
|
295
|
+
ATTRIBUTE_FIELD_OPTION_KEYS.each { |k| return [k, :attribute] if unsafe_attribute?(at(options, k)) }
|
|
296
|
+
return ['field_attributes', :attributes_json] if unsafe_attribute_json?(at(options, 'field_attributes'))
|
|
297
|
+
|
|
298
|
+
# An option label reaches BOTH positions no matter what the template says: it is element
|
|
299
|
+
# content inside the `<label>`, and the renderer also derives the control's `value="..."`
|
|
300
|
+
# from it. So it carries both rules, unlike `description`, which the template can only ever
|
|
301
|
+
# place as element content.
|
|
302
|
+
Array(at(options, :options)).each do |option|
|
|
303
|
+
label = at(option, 'label')
|
|
304
|
+
return ['option label', :markup] if unsafe_markup?(label)
|
|
305
|
+
return ['option label', :attribute] if unsafe_attribute?(label)
|
|
306
|
+
end
|
|
82
307
|
end
|
|
308
|
+
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Reads a key only from something that actually has keys. `try(:[], k)` looked equivalent and was
|
|
313
|
+
# not: on a String it reaches `String#[]`, which is substring search, so a scalar submitted where a
|
|
314
|
+
# hash was expected returned nil and every check on it reported safe.
|
|
315
|
+
def at(container, key)
|
|
316
|
+
return nil unless hashish?(container)
|
|
317
|
+
|
|
318
|
+
container[key]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def hashish?(value)
|
|
322
|
+
value.is_a?(Hash) || value.is_a?(ActionController::Parameters)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def scalarish?(value)
|
|
326
|
+
value.nil? || value.is_a?(String) || value.is_a?(Numeric)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def oversized?(value)
|
|
330
|
+
value.to_s.bytesize > MAX_GATED_VALUE_BYTES
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Refusals use flash.now, not flash: this renders rather than redirecting, so a plain flash would
|
|
334
|
+
# survive into the *next* page the author visits and report a failure that is no longer happening.
|
|
335
|
+
#
|
|
336
|
+
# Everything the author typed is carried back, so a refused save does not discard their work. The
|
|
337
|
+
# record is assigned but never saved, and `#fields`/`#the_settings` memoize off the assigned
|
|
338
|
+
# attributes, so `edit.html.erb` redraws the submitted form rather than the stored one.
|
|
339
|
+
def reject_save(message)
|
|
340
|
+
flash.now[:error] = message
|
|
341
|
+
@form.assign_attributes(name: params.dig(:plugins_cama_contact_form_cama_contact_form, :name),
|
|
342
|
+
slug: params.dig(:plugins_cama_contact_form_cama_contact_form, :slug))
|
|
343
|
+
repopulate_editor
|
|
344
|
+
edit
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Normalized to the shape `edit.html.erb` reads, not handed back raw: these are by definition the
|
|
348
|
+
# params that just failed validation, so a field may be a bare string or an option list a hash.
|
|
349
|
+
# A malformed field is dropped from the redraw rather than crashing it -- the message already names
|
|
350
|
+
# what was wrong with it. Best effort throughout: a refusal is never worth a second exception.
|
|
351
|
+
def repopulate_editor
|
|
352
|
+
submitted = params[:fields]
|
|
353
|
+
if hashish?(submitted)
|
|
354
|
+
redrawn = submitted.each_value.select { |field| hashish?(field) }.map do |field|
|
|
355
|
+
field = field.to_unsafe_h.dup if field.respond_to?(:to_unsafe_h)
|
|
356
|
+
options = field[:field_options]
|
|
357
|
+
field[:field_options] = hashish?(options) ? options : {}
|
|
358
|
+
field
|
|
359
|
+
end
|
|
360
|
+
@form.value = { fields: redrawn }.to_json
|
|
361
|
+
end
|
|
362
|
+
@form.settings = { 'railscf_mail' => params[:railscf_mail],
|
|
363
|
+
'railscf_message' => params[:railscf_message],
|
|
364
|
+
'railscf_form_button' => params[:railscf_form_button],
|
|
365
|
+
recaptcha_site_key: params[:recaptcha_site_key],
|
|
366
|
+
recaptcha_secret_key: params[:recaptcha_secret_key] }.to_json
|
|
367
|
+
rescue StandardError
|
|
368
|
+
nil
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def rejection_message(rejected, rule)
|
|
372
|
+
case rule
|
|
373
|
+
when :attribute
|
|
374
|
+
t('.unsafe_attribute_rejected', field: rejected,
|
|
375
|
+
default: 'The %{field} contains a double quote, which would break the HTML ' \
|
|
376
|
+
'attribute it is written into. Nothing was saved. Remove it, or ask ' \
|
|
377
|
+
'an administrator to grant the "Allow unfiltered HTML in contact ' \
|
|
378
|
+
'forms" permission.')
|
|
379
|
+
when :textarea
|
|
380
|
+
t('.unsafe_textarea_rejected', field: rejected,
|
|
381
|
+
default: 'The %{field} closes the text box it is written into. Nothing was ' \
|
|
382
|
+
'saved. Remove the closing tag, or ask an administrator to grant the ' \
|
|
383
|
+
'"Allow unfiltered HTML in contact forms" permission.')
|
|
384
|
+
when :attributes_json
|
|
385
|
+
t('.unsafe_attributes_rejected', field: rejected,
|
|
386
|
+
default: 'The %{field} contains an attribute your role is not permitted to ' \
|
|
387
|
+
'save: an event handler, an inline style, a script URL, or a ' \
|
|
388
|
+
'malformed name. Nothing was saved. Remove it, or ask an ' \
|
|
389
|
+
'administrator to grant the "Allow unfiltered HTML in contact forms" ' \
|
|
390
|
+
'permission.')
|
|
391
|
+
else
|
|
392
|
+
t('.unfiltered_html_rejected', field: rejected,
|
|
393
|
+
default: 'The %{field} contains HTML that your role is not permitted to save. ' \
|
|
394
|
+
'Nothing was saved. Remove it, or ask an administrator to grant the ' \
|
|
395
|
+
'"Allow unfiltered HTML in contact forms" permission.')
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# The containers the gate and the renderer both index into. Checked for everyone, before anything
|
|
400
|
+
# else, because a wrong shape here is not an authoring mistake to be judged by permission -- it is
|
|
401
|
+
# a record neither side can read. A missing container is fine (a partial update leaves it unset,
|
|
402
|
+
# and the model's readers default it); a container of the wrong type is not.
|
|
403
|
+
def first_malformed_shape_key(settings)
|
|
404
|
+
# `nil?`, not `blank?`: an absent `fields` means the caller is not editing them, but an empty
|
|
405
|
+
# string is a shape the editor never sends -- and `("" || {}).each` is a NoMethodError, because
|
|
406
|
+
# `""` is truthy.
|
|
407
|
+
fields_param = params[:fields]
|
|
408
|
+
return 'form fields' unless fields_param.nil? || hashish?(fields_param)
|
|
409
|
+
|
|
410
|
+
if hashish?(fields_param)
|
|
411
|
+
return 'field count' if fields_param.keys.size > MAX_FIELDS
|
|
412
|
+
|
|
413
|
+
fields_param.each_value do |field|
|
|
414
|
+
return 'form field' unless hashish?(field)
|
|
415
|
+
|
|
416
|
+
options = at(field, :field_options)
|
|
417
|
+
return 'field options' unless options.blank? || hashish?(options)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
{ 'railscf_mail' => 'mail settings', 'railscf_message' => 'response message',
|
|
422
|
+
'railscf_form_button' => 'submit button label' }.each do |key, name|
|
|
423
|
+
value = settings[key]
|
|
424
|
+
return name unless value.nil? || hashish?(value)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
MAIL_SCALAR_KEYS.each do |key|
|
|
428
|
+
value = at(settings['railscf_mail'], key)
|
|
429
|
+
return key unless scalarish?(value)
|
|
430
|
+
return key if oversized?(value)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
button = at(settings['railscf_form_button'], 'name_button')
|
|
434
|
+
return 'submit button label' unless scalarish?(button)
|
|
435
|
+
return 'submit button label' if oversized?(button)
|
|
436
|
+
|
|
437
|
+
each_leaf(settings['railscf_message']) { |v| return 'response message' if oversized?(v) }
|
|
438
|
+
|
|
439
|
+
ATTRIBUTE_SETTING_KEYS.each do |key|
|
|
440
|
+
return key.to_s unless settings[key].blank? || settings[key].is_a?(String)
|
|
441
|
+
return key.to_s if oversized?(settings[key])
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
nil
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# default_value lands in whichever position its field type renders: a paragraph or textarea
|
|
448
|
+
# redisplays it as textarea content, every other type puts it in a double-quoted value attribute.
|
|
449
|
+
# Sound only because `placeholder_in_tag?` guarantees `[ci]` is element content -- otherwise a
|
|
450
|
+
# template of `<div title="[ci]">` puts the whole textarea inside an attribute and neither rule
|
|
451
|
+
# describes the position. Returns the rule that refused it, or nil.
|
|
452
|
+
def unsafe_default_value_rule(field)
|
|
453
|
+
value = at(field, 'default_value')
|
|
454
|
+
if TEXTAREA_FIELD_TYPES.include?(at(field, :field_type).to_s)
|
|
455
|
+
unsafe_textarea?(value) ? :textarea : nil
|
|
456
|
+
else
|
|
457
|
+
unsafe_attribute?(value) ? :attribute : nil
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def permitted_messages
|
|
462
|
+
messages = params[:railscf_message]
|
|
463
|
+
return messages unless messages.respond_to?(:permit)
|
|
464
|
+
|
|
465
|
+
messages.permit(*MESSAGE_KEYS)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Every railscf_message value reaches the public page. The front controller joins them into
|
|
469
|
+
# flash[:contact_form] on both the failure and the success path, and the frontend flash partial
|
|
470
|
+
# renders that with `raw`.
|
|
471
|
+
def unsafe_messages?(messages)
|
|
472
|
+
each_leaf(messages).any? { |value| unsafe_markup?(value) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Lazy, so the first offending leaf stops the walk.
|
|
476
|
+
def each_leaf(value, &block)
|
|
477
|
+
return enum_for(:each_leaf, value) unless block
|
|
478
|
+
|
|
479
|
+
case value
|
|
480
|
+
when ActionController::Parameters, Hash then value.each_value { |v| each_leaf(v, &block) }
|
|
481
|
+
when Array then value.each { |v| each_leaf(v, &block) }
|
|
482
|
+
else yield value
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Returns the name of the first malformed structural value, or nil. Same rule: the key only, never
|
|
487
|
+
# the value the author submitted.
|
|
488
|
+
def first_malformed_structural_key(fields)
|
|
489
|
+
fields.each do |field|
|
|
490
|
+
return 'cid' unless at(field, :cid).to_s.match?(CID_FORMAT)
|
|
491
|
+
|
|
492
|
+
type = at(field, :field_type).to_s
|
|
493
|
+
return 'field type' unless FIELD_TYPES.include?(type)
|
|
494
|
+
return 'required flag' unless scalarish?(at(field, :required))
|
|
495
|
+
|
|
496
|
+
options = at(field, :field_options)
|
|
497
|
+
maxlength = at(options, 'maxlength')
|
|
498
|
+
return 'maxlength' if maxlength.present? && !maxlength.to_s.match?(/\A\d+\z/)
|
|
499
|
+
|
|
500
|
+
option_list = Array(at(options, :options))
|
|
501
|
+
return 'option count' if option_list.size > MAX_OPTIONS_PER_FIELD
|
|
502
|
+
|
|
503
|
+
# A field type that renders a list of choices needs at least one, and every option needs a
|
|
504
|
+
# label: `options.each` and `op[:label].translate` are both unguarded in the renderer, so
|
|
505
|
+
# either omission is a NoMethodError on every visit, from a save the author was told succeeded.
|
|
506
|
+
return 'options' if OPTION_FIELD_TYPES.include?(type) && option_list.empty?
|
|
507
|
+
|
|
508
|
+
option_list.each do |option|
|
|
509
|
+
return 'option label' unless hashish?(option) && at(option, 'label').is_a?(String)
|
|
510
|
+
return 'option label' if oversized?(at(option, 'label'))
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Every substituted slot must be a string for the same reason: `["<div>[ci]</div>"].to_s` is
|
|
514
|
+
# sanitize-stable, so an array-valued template passes the content gate and then reaches
|
|
515
|
+
# `String#sub` in the renderer.
|
|
516
|
+
(MARKUP_FIELD_KEYS + ATTRIBUTE_FIELD_KEYS + %w[default_value]).each do |k|
|
|
517
|
+
return k unless scalarish?(at(field, k))
|
|
518
|
+
return k if oversized?(at(field, k))
|
|
519
|
+
end
|
|
520
|
+
(MARKUP_FIELD_OPTION_KEYS + ATTRIBUTE_FIELD_OPTION_KEYS + %w[field_attributes]).each do |k|
|
|
521
|
+
return k unless scalarish?(at(options, k))
|
|
522
|
+
return k if oversized?(at(options, k))
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Binds everyone, including a grant holder. A placeholder inside a tag makes the substituted
|
|
526
|
+
# value's HTML context depend on who wrote the template rather than on what the value is, and
|
|
527
|
+
# `field_attributes` that is not a JSON object reaches `{id: cid}.merge(...)` in the renderer
|
|
528
|
+
# and raises TypeError on every public page.
|
|
529
|
+
return 'template' if placeholder_in_tag?(at(options, 'template'))
|
|
530
|
+
return 'field_attributes' if malformed_attribute_json?(at(options, 'field_attributes'))
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
nil
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# Every gated value is checked in each of the forms it can reach the page as, not only as written.
|
|
537
|
+
# `String#translate` picks a locale-specific substring and *deletes* the `<!--:xx-->` markers on the
|
|
538
|
+
# way, so the renderer emits a string the gate never saw. Checking only the stored form let an
|
|
539
|
+
# author split a payload across a marker -- `</tex<!--:en-->tarea>` -- and have the renderer put it
|
|
540
|
+
# back together.
|
|
541
|
+
def rendered_forms(value)
|
|
542
|
+
string = value.to_s
|
|
543
|
+
return [] if string.blank?
|
|
544
|
+
|
|
545
|
+
([string] + string.translations_array.map(&:to_s) + [string.gsub(TRANSLATION_MARKER, '')]).uniq
|
|
546
|
+
rescue StandardError
|
|
547
|
+
[string]
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Each rendered form is judged by CamaleonCms::UnsafeMarkup, the core scan-and-reject detector this
|
|
551
|
+
# gate is kept in parity with. It parses once and refuses the value when the safe-list scrubber
|
|
552
|
+
# would remove anything, when a kept attribute smuggles markup (`title="<img ...>"` reaching a
|
|
553
|
+
# `data-html` sink), when dangerous inline CSS survives, or when the value carries one of the
|
|
554
|
+
# structural shapes an HTML parser silently repairs -- an unterminated tag or attribute, a
|
|
555
|
+
# foster-parented table cell, a translation marker inside a tag. MARKUP_TAGS/MARKUP_ATTRS widen
|
|
556
|
+
# Rails' safe list with the layout elements these positions carry; `data-`/`aria-` attributes are
|
|
557
|
+
# admitted by shape by the detector itself.
|
|
558
|
+
def unsafe_markup?(value)
|
|
559
|
+
rendered_forms(value).any? do |form|
|
|
560
|
+
CamaleonCms::UnsafeMarkup.unsafe_html?(form, tags: MARKUP_TAGS, attributes: MARKUP_ATTRS)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# A template must not put one of the renderer's own placeholders inside a tag: doing so decides the
|
|
565
|
+
# HTML context of a value written somewhere else, possibly by someone else. Structural rather than
|
|
566
|
+
# parse-based (see token_inside_tag?), because an HTML parser silently repairs the shape.
|
|
567
|
+
def placeholder_in_tag?(value)
|
|
568
|
+
string = value.to_s
|
|
569
|
+
return false unless string.include?('[')
|
|
570
|
+
|
|
571
|
+
token_inside_tag?(string, PLACEHOLDER)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Collects the value's tags and reports whether `token` appears inside any of them. Structural
|
|
575
|
+
# rather than parse-based, because an HTML parser silently repairs both of the shapes this exists
|
|
576
|
+
# to catch. `token` is listed first in the alternation so a token standing on its own is consumed
|
|
577
|
+
# as itself and not mistaken for a tag; one swallowed by a real tag span is not.
|
|
578
|
+
def token_inside_tag?(string, token)
|
|
579
|
+
tags = +''
|
|
580
|
+
string.scan(/#{token}|#{TAG_SPAN}/) { |match| tags << match unless match.match?(/\A#{token}\z/) }
|
|
581
|
+
tags.match?(token)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def unsafe_attribute?(value)
|
|
585
|
+
rendered_forms(value).any? { |form| form.include?('"') }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# RCDATA: only the closing tag ends a textarea, so that sequence is the whole of what is refused.
|
|
589
|
+
# Quotes, ampersands and angle brackets are ordinary characters in a prefilled message.
|
|
590
|
+
def unsafe_textarea?(value)
|
|
591
|
+
rendered_forms(value).any? { |form| form.downcase.include?('</textarea') }
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Valid JSON that is not an object. Structural, because the renderer's `rescue {}` binds only to
|
|
595
|
+
# `JSON.parse`, so a well-formed `[1,2]` parses, reaches `{id: cid}.merge(...)` and raises
|
|
596
|
+
# TypeError -- a permanent 500 on every public page, from a save reported as successful. Held to
|
|
597
|
+
# everyone, grant holders included: it is a corrupt record, not a capability.
|
|
598
|
+
def malformed_attribute_json?(value)
|
|
599
|
+
return false if value.blank?
|
|
600
|
+
|
|
601
|
+
parsed = parse_attribute_json(value)
|
|
602
|
+
return false if parsed == :unparseable
|
|
603
|
+
|
|
604
|
+
!parsed.is_a?(Hash)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# field_attributes is a JSON object emitted verbatim as attribute name/value pairs, so an untrusted
|
|
608
|
+
# author may write neither a name that escapes the tag nor one that is itself script:
|
|
609
|
+
#
|
|
610
|
+
# a name that is not a valid attribute name renders as several attributes;
|
|
611
|
+
# a value containing a double quote closes its own;
|
|
612
|
+
# a name beginning with `on` is an event handler and runs whatever the value says;
|
|
613
|
+
# a name carrying a URL executes whatever scheme that URL names;
|
|
614
|
+
# `style` is refused outright, being enough on its own to cover the viewport with an overlay.
|
|
615
|
+
def unsafe_attribute_json?(value)
|
|
616
|
+
return false if value.blank?
|
|
617
|
+
|
|
618
|
+
parsed = parse_attribute_json(value)
|
|
619
|
+
return false unless parsed.is_a?(Hash)
|
|
620
|
+
|
|
621
|
+
parsed.any? { |k, v| unsafe_attribute_pair?(k, v) }
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def parse_attribute_json(value)
|
|
625
|
+
JSON.parse(value.to_s)
|
|
626
|
+
rescue StandardError
|
|
627
|
+
:unparseable
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def unsafe_attribute_pair?(name, value)
|
|
631
|
+
name = name.to_s
|
|
632
|
+
return true unless name.match?(HTML_ATTR_NAME)
|
|
633
|
+
return true if name.match?(EVENT_HANDLER_ATTR_NAME)
|
|
634
|
+
return true if REFUSED_ATTR_NAMES.include?(name.downcase)
|
|
635
|
+
return true if value.to_s.include?('"')
|
|
636
|
+
|
|
637
|
+
URL_BEARING_ATTR_NAMES.include?(name.downcase) && dangerous_url?(value)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Decoded the way the browser will decode it. `CGI.unescapeHTML` handles only the five legacy
|
|
641
|
+
# entities and numeric references, so `javascript:alert(1)` and `java	script:alert(1)`
|
|
642
|
+
# walked straight past a hand-rolled check -- and the HTML parser resolves both to `javascript:`.
|
|
643
|
+
# The parser is the authority on that question, so it is the one asked. The value is known to
|
|
644
|
+
# carry no double quote by the time this runs.
|
|
645
|
+
def dangerous_url?(value)
|
|
646
|
+
decoded = Loofah.html5_fragment(%(<a href="#{value}">)).at_css('a')&.[]('href').to_s
|
|
647
|
+
decoded.gsub(URL_IGNORABLE_CHARS, '').match?(DANGEROUS_URL_SCHEME)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def set_form
|
|
651
|
+
@form = current_site.contact_forms.find_by(id: params[:id])
|
|
652
|
+
rescue StandardError
|
|
653
|
+
flash[:error] = t('.error_form_class', default: 'Error form class')
|
|
654
|
+
redirect_to cama_admin_path
|
|
83
655
|
end
|
|
84
656
|
end
|