inline_forms 8.1.56 → 8.1.57
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/CHANGELOG.md +66 -0
- data/app/controllers/inline_forms_controller.rb +49 -5
- data/app/helpers/inline_forms_file_trash_helper.rb +10 -0
- data/app/models/concerns/inline_forms/stored_files.rb +5 -1
- data/app/models/inline_forms/trashed_file.rb +16 -2
- data/app/views/devise/sessions/_form.html.erb +2 -2
- data/app/views/inline_forms/_list.html.erb +24 -7
- data/app/views/inline_forms/_versions.html.erb +1 -1
- data/app/views/inline_forms/_versions_file_entry.html.erb +1 -1
- data/app/views/inline_forms/_versions_list.html.erb +14 -12
- data/app/views/layouts/application.html.erb +10 -3
- data/app/views/layouts/devise.html.erb +1 -1
- data/app/views/layouts/inline_forms.html.erb +9 -2
- data/lib/inline_forms/form_elements/file_field_helper.rb +3 -1
- data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
- data/lib/inline_forms/version.rb +1 -1
- data/lib/locales/inline_forms.en.yml +22 -1
- data/lib/locales/inline_forms.nl.yml +25 -1
- data/test/dummy/config/application.rb +11 -0
- data/test/integration/csp_nonce_test.rb +50 -0
- data/test/integration/file_slots_test.rb +91 -0
- data/test/integration/list_params_test.rb +200 -0
- data/test/integration/locale_test.rb +113 -0
- data/test/integration/presentation_escaping_test.rb +80 -0
- metadata +9 -2
- data/app/views/inline_forms/_flash.html.erb +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93e0cd0c2bc09c666bd81cf76779b780ab345f4a7e80f8a6535177d7921b150d
|
|
4
|
+
data.tar.gz: 02e8abc26971fe105a65b8bae7380d171e2635e1e51385c0849917e1a5365c16
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ecdf5c3b1c43851b0cd0d6b05fdac85a51b227c30584a52bb395c9786c1dffdb9378e549b6d99a1faf5ff11aa8bfc7ecda01ae1b92e494cf2dfdf9750fc3fae4
|
|
7
|
+
data.tar.gz: df8a5b2b0cd7a9f21a9b14e58a086ed7ace99f9ce6380bd1e8284cae6b65183b4db72e15de2c8ffa332e9366d85780dc6ccb125e12bfa7afa4a24ed204383a7a
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,72 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [8.1.57] - 2026-10-05
|
|
8
|
+
|
|
9
|
+
Fixes from a security/UI review of a host app (StProject), plus missing and hard-coded translations found when StProject switched to `:nl` with `raise_on_missing_translations = :strict`.
|
|
10
|
+
|
|
11
|
+
### Security
|
|
12
|
+
|
|
13
|
+
- **Reflected XSS through the nested list's frame id.** `_list` built the list frame as `raw "<turbo-frame id=\"#{update_span}\" …>"`, with `update_span` made from `params[:parent_id]`, and nothing looked the parent up before rendering. So `/incidents?parent_class=Client&ul_needed=1&update=x&parent_id="><img src=x onerror=…>` rendered the payload. Two fixes:
|
|
14
|
+
- The list body is now captured and wrapped with `tag.turbo_frame(id:, class: "list_container")`, so the id is attribute-escaped. No other view or helper puts a request param into `raw` / `html_safe` markup.
|
|
15
|
+
- `InlineFormsController` refuses malformed parent params with **400** in `index`, `new`, `create` and `show` (new `before_action :refuse_malformed_parent_params`). `parent_id` must match `/\A\d+\z/`. `parent_class` must name the class of a non-polymorphic `belongs_to` of `@Klass` that is named after it (`Photo#apartment` for `parent_class=Apartment`), which is exactly what the gem's own links send. The check runs before `_list`'s `constantize` and before `new`/`create` write the foreign key, and after `load_and_authorize_resource`, so a host's `check_authorization` stays satisfied.
|
|
16
|
+
- **Stored XSS in `info_list`.** `info_list_show` appended `item._presentation` unescaped and then marked the string `html_safe`. A `_presentation` built from user data (StProject: `User#name` listed on a Role) therefore ran as markup. It is now escaped with `ERB::Util.h`. Audit of the other helpers that mark strings `html_safe`:
|
|
17
|
+
- `file_field_show` now escapes the stored file name and URL.
|
|
18
|
+
- The versions list rendered a rich-text changeset with `raw`. It is now passed through `sanitize`, so a body posted outside Trix cannot carry script.
|
|
19
|
+
- `dropdown`, `dropdown_with_other`, `check_list`, `radio_button`, the list rows, `_close`, `_show` and the trash pages already escaped `_presentation` (via `link_to` / `link_to_inline_edit` / `h` / ERB) and are unchanged.
|
|
20
|
+
- **Hosts with their own `inline_forms/_versions_list.html.erb` (StProject has one) keep the `raw`** until they apply the same change.
|
|
21
|
+
- **Restoring a trashed file verifies its bytes.** `InlineForms::TrashedFile#restore!` and `InlineForms::StoredFiles.prepare_revert!` (undo of a destroy) now compare the SHA-256 of `data` with the stored `checksum` before writing bytes back into the slot (new `TrashedFile.verify_checksum!`). On a mismatch they log an error (`inline_forms file_trash checksum mismatch entry=<id>`) and raise `NotRestorable`: the transaction rolls back, nothing is written and the row stays in the trash. The field shows "can no longer be restored". The revert answers **422** instead of a 500; this also covers a `NotRestorable` from a concurrent purge.
|
|
22
|
+
- **Inline scripts carry the CSP nonce.** The Turbo module `<script>` in `layouts/inline_forms` and `layouts/application` is now rendered with `nonce: content_security_policy_nonce`, which adds no attribute without a nonce generator. It is also marked `data-turbo-eval="false"`, and both layouts emit `csp_meta_tag`. This is the same as StProject's own layout, so a `script-src 'nonce-…'` policy accepts the gem's layouts.
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
|
|
26
|
+
- **`inline_forms/_flash.html.erb`.** It had an inline jQuery `<script>` (no nonce), and neither the gem, the installer, the example app nor StProject rendered it. The layouts show flash themselves, and Devise pages use `devise/sessions/_flash`. A host that still does `render "inline_forms/flash"` must provide its own partial.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **File-slot actions keep the page's slots.** `FileSlotsConcern` has no per-tab `@inline_forms_attribute_list`. After a remove, restore or undo, the re-rendered field (`_file_slot`) and the versions panel built `slots=` from the model's full attribute list. On a tabbed host (StProject's Client), the next action's trash panel then listed files from other tabs' slots. `inline_forms_file_trash_slots` now uses the action's `@file_trash_slots` for its record. That value comes from the page's own `slots` param and is reduced to declared, listed, readable slots, as before.
|
|
31
|
+
- **Pagination of the top-level list keeps a host's filters, now under test.** A host's own filters (`first_name`, `sorteer`, `group[id]`, `status[id]`, …) stay on page 2. will_paginate already did that: it puts the request's params into every page link (all params on GET, the query string otherwise). The only change is that the redundant explicit `search` param is dropped. The page links deliberately do **not** merge `request.query_parameters` themselves. That skips will_paginate's exclusion of `script_name` / `original_script_name`, so `?script_name=javascript:alert(1)//` would become the start of every page link's `href` (XSS), and `?controller=x` would raise a 500.
|
|
32
|
+
|
|
33
|
+
- **The create flash was never translated.** `InlineFormsController#create` calls `t("success", message: …)`, but both locale files defined the key as `succes`, so every create looked up a missing translation (and raised under `raise_on_missing_translations`). The key is now `success` (en "%{message} created.", nl "%{message} is aangemaakt."). The misspelled `succes` is dropped: it never received the message, and nothing else used it.
|
|
34
|
+
- **The versions panel is translated.** `_versions` and `_versions_list` hard-coded English: "Versions (n)", Event, When, Done by, Changeset, old value / new value, empty, "(rich text)" and the raw event names. They now use `inline_forms.view.versions` (with `count`), `versions_event`, `versions_when`, `versions_done_by`, `versions_changeset`, `versions_old_value`, `versions_new_value`, `versions_empty`, `versions_rich_text` and `version_events.{create,update,destroy}`. Unknown events fall back to the raw name. en and nl are included, with the same key names as StProject's overrides, so those overrides can be dropped.
|
|
35
|
+
- **Missing view keys:**
|
|
36
|
+
- nl now has `inline_forms.view.restore`, `list_versions`, `close_versions_list` and `undelete`.
|
|
37
|
+
- en now has `undelete`, used by the soft-restore link (`link_to_soft_delete`).
|
|
38
|
+
- Both locales now have `common.more` and `common.logout`, which the engine header uses and which only hosts defined before.
|
|
39
|
+
- **Version timestamps are localized.** The versions list and its file events printed `created_at` / the event time raw (`2026-10-05 15:10:24 UTC`). They now use `l(…, format: :short)`.
|
|
40
|
+
- **`<html lang>`.** `layouts/devise` and `layouts/application` now declare `lang="<%= I18n.locale %>"`, like the `inline_forms` layout.
|
|
41
|
+
- **The sign-in button is translated.** `devise/sessions/_form` used `f.submit :login` (always "login"). It now uses `t("inline_forms.devise.login", default: "login")`: en "login", nl "inloggen".
|
|
42
|
+
|
|
43
|
+
### Tests
|
|
44
|
+
|
|
45
|
+
- `test/integration/list_params_test.rb`:
|
|
46
|
+
- a crafted `parent_id` (frame request and full page) → 400 with the payload never in the body;
|
|
47
|
+
- a non-association or wrongly cased `parent_class` → 400, and a non-integer `parent_id` → 400;
|
|
48
|
+
- crafted `new` / `create` / `show` → 400, and `create` creates nothing;
|
|
49
|
+
- every refusal counts as authorized;
|
|
50
|
+
- well-formed nested list, new and create still work, including the `not_accessible_through_html?` child;
|
|
51
|
+
- `_list` escapes the frame id by itself;
|
|
52
|
+
- page 2 of a filtered list (and back to page 1) carries every filter, and so does the list rendered after a create;
|
|
53
|
+
- a crafted `script_name` / `original_script_name` / `controller` in the query string never reaches the page links.
|
|
54
|
+
- `test/integration/presentation_escaping_test.rb`: `info_list`, `file_field`, `dropdown_with_other` and the list rows escape `<img src=x onerror=…>`; the versions list sanitizes a rich-text changeset.
|
|
55
|
+
- `test/integration/file_slots_test.rb`:
|
|
56
|
+
- a restore re-renders with the page's `slots` only, so neither panel lists the other slot's file, and `slots` is reduced to declared slots;
|
|
57
|
+
- a tampered trash row (bytes or checksum) is refused by `restore_file` and by `restore!` (logged, nothing written);
|
|
58
|
+
- undo of a destroy with a tampered row → 422 with nothing restored.
|
|
59
|
+
- `test/integration/csp_nonce_test.rb`: the dummy app now runs a nonce CSP (report-only, like StProject). Both layouts' inline scripts carry the request's nonce and publish it in `csp-nonce`, and `inline_forms/_flash` is gone.
|
|
60
|
+
- `test/integration/locale_test.rb`. The dummy app now loads `rails-i18n`, as host Gemfiles do. Tests:
|
|
61
|
+
- every key above exists in en and nl, and `succes` is gone;
|
|
62
|
+
- create's flash is translated in nl;
|
|
63
|
+
- the versions panel and list render in nl with translated labels, event names and a localized timestamp, and in en with the English labels. Both render with missing translations raising, in views and controllers;
|
|
64
|
+
- the devise and application layouts declare `lang`.
|
|
65
|
+
- Example app:
|
|
66
|
+
- `example_app_turbo_layout_test.rb` accepts the Turbo module script with its new attributes;
|
|
67
|
+
- new `example_app_devise_sign_in_test.rb`: the sign-in page has `<html lang>` and the translated submit button.
|
|
68
|
+
|
|
69
|
+
### Lockstep
|
|
70
|
+
|
|
71
|
+
- validation_hints 8.1.57 (now ships an nl locale, and `message: :required` hints, i.e. every `belongs_to`, resolve in en and nl), inline_forms_installer 8.1.57, inline_forms_schema_edit 8.1.57.
|
|
72
|
+
|
|
7
73
|
## [8.1.56] - 2026-10-05
|
|
8
74
|
|
|
9
75
|
### Fixed
|
|
@@ -32,6 +32,9 @@ class InlineFormsController < ApplicationController
|
|
|
32
32
|
|
|
33
33
|
# +revert+ and the file-slot actions authorize themselves (attribute-level).
|
|
34
34
|
load_and_authorize_resource except: [ :revert, *FileSlotsConcern::FILE_SLOT_ACTIONS ], no_params: true if cancan_enabled?
|
|
35
|
+
# After the authorization above, so a refusal still satisfies a host's
|
|
36
|
+
# check_authorization (see refuse_malformed_parent_params).
|
|
37
|
+
before_action :refuse_malformed_parent_params, only: [ :index, :new, :create, :show ]
|
|
35
38
|
# :index shows a list of all objects from class @Klass, using will_paginate,
|
|
36
39
|
# including a link to 'new', that allows you to create a new record.
|
|
37
40
|
def index
|
|
@@ -380,11 +383,18 @@ class InlineFormsController < ApplicationController
|
|
|
380
383
|
else
|
|
381
384
|
# One transaction: the record, its files coming back out of the trash
|
|
382
385
|
# (undo of a destroy) and its rich texts are restored together or not.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
386
|
+
begin
|
|
387
|
+
@object.class.transaction do
|
|
388
|
+
file_entries = InlineForms::StoredFiles.prepare_revert!(@object)
|
|
389
|
+
@parent = persist_reverted_primary!(@object, @version)
|
|
390
|
+
InlineForms::StoredFiles.finish_revert!(file_entries, by: inline_forms_current_user_id)
|
|
391
|
+
restore_rich_texts_for_reverted_parent!(@parent)
|
|
392
|
+
end
|
|
393
|
+
rescue InlineForms::TrashedFile::NotRestorable
|
|
394
|
+
# A trashed file failed its checksum, or was purged/restored
|
|
395
|
+
# concurrently: the transaction rolled back and nothing was restored.
|
|
396
|
+
# An explicit answer instead of a 500.
|
|
397
|
+
return head :unprocessable_entity
|
|
388
398
|
end
|
|
389
399
|
@parent.reload
|
|
390
400
|
end
|
|
@@ -393,6 +403,40 @@ class InlineFormsController < ApplicationController
|
|
|
393
403
|
|
|
394
404
|
private
|
|
395
405
|
|
|
406
|
+
# +parent_class+ / +parent_id+ come from the nested-list links and are
|
|
407
|
+
# client-controlled, yet +_list+ constantizes +parent_class+, +new+ and
|
|
408
|
+
# +create+ write +parent_id+ into the foreign key, and both land in the
|
|
409
|
+
# list's frame ids. Nothing +find+s the parent before the list renders, so a
|
|
410
|
+
# crafted +parent_id+ (markup) reached the page (reflected XSS), and any
|
|
411
|
+
# +parent_class+ string reached +constantize+. Refuse (400) unless
|
|
412
|
+
# +parent_id+ is a plain integer id and +parent_class+ names the class of a
|
|
413
|
+
# belongs_to association of @Klass named after it (Photo#apartment for
|
|
414
|
+
# +parent_class=Apartment+): exactly what the gem's own links send.
|
|
415
|
+
def refuse_malformed_parent_params
|
|
416
|
+
parent_class = params[:parent_class]
|
|
417
|
+
parent_id = params[:parent_id]
|
|
418
|
+
return if parent_class.blank? && parent_id.blank?
|
|
419
|
+
return if parent_id_param?(parent_id) && (parent_class.blank? || parent_class_param?(parent_class))
|
|
420
|
+
|
|
421
|
+
head :bad_request
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def parent_id_param?(parent_id)
|
|
425
|
+
parent_id.is_a?(String) && parent_id.match?(/\A\d+\z/)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Compared against the reflections (no +to_sym+ / +constantize+ of the
|
|
429
|
+
# param): a non-polymorphic belongs_to whose name is the underscored class.
|
|
430
|
+
def parent_class_param?(parent_class)
|
|
431
|
+
return false unless parent_class.is_a?(String)
|
|
432
|
+
|
|
433
|
+
@Klass.reflect_on_all_associations(:belongs_to).any? do |reflection|
|
|
434
|
+
!reflection.polymorphic? &&
|
|
435
|
+
reflection.class_name == parent_class &&
|
|
436
|
+
reflection.name.to_s == parent_class.underscore
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
396
440
|
# Single-field requests (edit, update, show with an attribute) name the
|
|
397
441
|
# field and its form element in params, and both are client-controlled.
|
|
398
442
|
# +load_and_authorize_resource+ only authorizes the record: CanCanCan's
|
|
@@ -24,7 +24,17 @@ module InlineFormsFileTrashHelper
|
|
|
24
24
|
|
|
25
25
|
# Declared, routed slots among the rows of the attribute list being shown
|
|
26
26
|
# (a Client tab shows only its own slots).
|
|
27
|
+
#
|
|
28
|
+
# The file-slot actions (FileSlotsConcern) re-render the field, the trash
|
|
29
|
+
# panel and the versions panel as streams, and that controller has no
|
|
30
|
+
# per-tab @inline_forms_attribute_list: falling back to the model's full
|
|
31
|
+
# list put every tab's slots into the re-rendered links (slots=…), so the
|
|
32
|
+
# next action's panel listed other tabs' files. Those actions set
|
|
33
|
+
# @file_trash_slots from the page's own +slots+ param (validated against
|
|
34
|
+
# the declared, listed, readable slots); use it for their record.
|
|
27
35
|
def inline_forms_file_trash_slots(object, attributes = nil)
|
|
36
|
+
return Array(@file_trash_slots) if attributes.nil? && !@file_trash_slots.nil? && object == @object
|
|
37
|
+
|
|
28
38
|
attributes ||= @inline_forms_attribute_list || object.inline_forms_attribute_list
|
|
29
39
|
attributes.filter_map do |attribute, form_element, *|
|
|
30
40
|
next unless form_element.to_s == "simple_file_field"
|
|
@@ -56,7 +56,8 @@ module InlineForms::StoredFiles
|
|
|
56
56
|
# * undo of a destroy (row gone): copy each slot's newest restorable
|
|
57
57
|
# record_destroyed entry onto the record BEFORE the save, so presence
|
|
58
58
|
# validations on the filename pass; a slot whose file was purged in the
|
|
59
|
-
# meantime comes back empty (never a name without bytes).
|
|
59
|
+
# meantime comes back empty (never a name without bytes). An entry whose
|
|
60
|
+
# bytes fail their SHA-256 check raises NotRestorable (nothing restored).
|
|
60
61
|
# * row exists already (replayed undo): nothing; persist_reverted_primary!
|
|
61
62
|
# leaves the slot columns alone.
|
|
62
63
|
def self.prepare_revert!(record)
|
|
@@ -73,6 +74,9 @@ module InlineForms::StoredFiles
|
|
|
73
74
|
entry = InlineForms::TrashedFile.lock.for_slot(record, attribute)
|
|
74
75
|
.where(reason: "record_destroyed").restorable.newest_first.first
|
|
75
76
|
if entry
|
|
77
|
+
# Same integrity check as TrashedFile#restore!: a mismatch raises
|
|
78
|
+
# NotRestorable and the whole revert rolls back.
|
|
79
|
+
InlineForms::TrashedFile.verify_checksum!(id: entry.id, data: entry.data, checksum: entry.checksum)
|
|
76
80
|
record.send(:write_attribute, slot[:data], entry.data)
|
|
77
81
|
record.send(:write_attribute, attribute.to_s, entry.filename)
|
|
78
82
|
record.send(:write_attribute, slot[:content_type], entry.content_type)
|
|
@@ -85,6 +85,18 @@ class InlineForms::TrashedFile < ActiveRecord::Base
|
|
|
85
85
|
)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Restoring writes the stored bytes back into a live slot, so they must be
|
|
89
|
+
# the bytes that were trashed: compares the SHA-256 recorded by trash!
|
|
90
|
+
# (nil data has a nil checksum). Raises NotRestorable on a mismatch, before
|
|
91
|
+
# anything is written, so the caller's transaction rolls back; the mismatch
|
|
92
|
+
# is logged (a tampered or corrupted trash row needs a look).
|
|
93
|
+
def self.verify_checksum!(id:, data:, checksum:)
|
|
94
|
+
return if checksum == (data && Digest::SHA256.hexdigest(data))
|
|
95
|
+
|
|
96
|
+
Rails.logger.error("inline_forms file_trash checksum mismatch entry=#{id}: not restored")
|
|
97
|
+
raise NotRestorable, "this file failed its integrity check and cannot be restored"
|
|
98
|
+
end
|
|
99
|
+
|
|
88
100
|
# Purges every trash row whose retention has passed. Returns the number
|
|
89
101
|
# purged. Idempotent; safe to run while users work.
|
|
90
102
|
def self.purge_expired!
|
|
@@ -189,14 +201,16 @@ class InlineForms::TrashedFile < ActiveRecord::Base
|
|
|
189
201
|
record = record_class.unscoped.lock.find_by(record_class.primary_key => record_id)
|
|
190
202
|
raise RecordGone, "the record was deleted" unless record
|
|
191
203
|
|
|
192
|
-
payload = self.class.lock.where(id: id).restorable.pick(:data, :filename, :content_type)
|
|
204
|
+
payload = self.class.lock.where(id: id).restorable.pick(:data, :filename, :content_type, :checksum)
|
|
193
205
|
raise NotRestorable, "this file can no longer be restored" unless payload
|
|
194
206
|
|
|
207
|
+
data, filename, content_type, checksum = payload
|
|
208
|
+
self.class.verify_checksum!(id: id, data: data, checksum: checksum)
|
|
209
|
+
|
|
195
210
|
now = Time.current
|
|
196
211
|
count = self.class.where(id: id).restorable.update_all(data: nil, restored_at: now, restored_by_id: by, updated_at: now)
|
|
197
212
|
raise NotRestorable, "this file can no longer be restored" unless count == 1
|
|
198
213
|
|
|
199
|
-
data, filename, content_type = payload
|
|
200
214
|
record.inline_forms_put_file!(attribute_name, data: data, filename: filename, content_type: content_type, by: by)
|
|
201
215
|
reload
|
|
202
216
|
record
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
<%= f.check_box :remember_me %><label><%= t :remember_me %></label>
|
|
27
27
|
</div>
|
|
28
28
|
<div class="small-7 columns text-right">
|
|
29
|
-
<%= f.submit :login, class: 'button' %>
|
|
29
|
+
<%= f.submit t("inline_forms.devise.login", default: "login"), class: 'button' %>
|
|
30
30
|
</div>
|
|
31
31
|
</div>
|
|
32
32
|
<% else %>
|
|
33
33
|
<div class="row">
|
|
34
34
|
<div class="columns text-right">
|
|
35
|
-
<%= f.submit :login, class: 'button' %>
|
|
35
|
+
<%= f.submit t("inline_forms.devise.login", default: "login"), class: 'button' %>
|
|
36
36
|
</div>
|
|
37
37
|
</div>
|
|
38
38
|
<% end -%>
|
|
@@ -42,11 +42,16 @@
|
|
|
42
42
|
<% end %>
|
|
43
43
|
<% end %>
|
|
44
44
|
|
|
45
|
-
<%#
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
<%#
|
|
46
|
+
List root is `<turbo-frame class="list_container">` (Step 4). See SCSS.
|
|
47
|
+
The rows and pagination are captured and wrapped with tag.turbo_frame at
|
|
48
|
+
the end, so the frame id is attribute-escaped: it is built from request
|
|
49
|
+
params (parent_class / parent_id / update), and the former
|
|
50
|
+
`raw "<turbo-frame id=\"#{update_span}\" …>"` put a crafted parent_id
|
|
51
|
+
straight into the page (reflected XSS). The controller also refuses
|
|
52
|
+
malformed parent params (refuse_malformed_parent_params).
|
|
53
|
+
-%>
|
|
54
|
+
<% list_body = capture do -%>
|
|
50
55
|
<!-- # list of objects -->
|
|
51
56
|
<% for object in objects %>
|
|
52
57
|
<% if parent_class.nil? %>
|
|
@@ -111,8 +116,17 @@
|
|
|
111
116
|
<% end -%>
|
|
112
117
|
<!-- # pagination -->
|
|
113
118
|
<% if parent_id.nil? -%>
|
|
119
|
+
<%#
|
|
120
|
+
Page 2 of a filtered list must stay filtered (search and a host's own
|
|
121
|
+
filters such as first_name / sorteer / group[id]). will_paginate does
|
|
122
|
+
that itself: it puts the request's params (all params on GET, the query
|
|
123
|
+
string otherwise) into every page link, except script_name and
|
|
124
|
+
original_script_name. Do not merge request.query_parameters in here:
|
|
125
|
+
explicit params skip that exclusion, so ?script_name=javascript:… became
|
|
126
|
+
the start of every page link's href (XSS), and ?controller=x a 500.
|
|
127
|
+
test/integration/list_params_test.rb covers both.
|
|
128
|
+
-%>
|
|
114
129
|
<% paginate_params = { update: update_span, ul_needed: true } %>
|
|
115
|
-
<% paginate_params[:search] = params[:search] if params[:search].present? %>
|
|
116
130
|
<% pagination = will_paginate objects, params: paginate_params %>
|
|
117
131
|
<% else %>
|
|
118
132
|
<% if pagination %>
|
|
@@ -144,6 +158,9 @@
|
|
|
144
158
|
</div>
|
|
145
159
|
</div>
|
|
146
160
|
<% end %>
|
|
161
|
+
<% end -%>
|
|
147
162
|
<% if ul_needed -%>
|
|
148
|
-
<%=
|
|
163
|
+
<%= tag.turbo_frame(list_body, id: update_span, class: "list_container") -%>
|
|
164
|
+
<% else -%>
|
|
165
|
+
<%= list_body -%>
|
|
149
166
|
<% end -%>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
(parent + rich_text), flickering as the user toggled the panel. -%>
|
|
8
8
|
<div class="row form_element_header associated_auto_header callout">
|
|
9
9
|
<div class='medium-11 large-11 column' >
|
|
10
|
-
<%= "
|
|
10
|
+
<%= t("inline_forms.view.versions", count: inline_forms_history_for(object).length) %>
|
|
11
11
|
</div>
|
|
12
12
|
<div class='medium-1 large-1 column'>
|
|
13
13
|
<%= link_to_versions_list(
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<% restorable_files = inline_forms_restorable_file_entries(@object, entries.filter_map { |e| e[:entry]&.attribute_name }.uniq) -%>
|
|
5
5
|
<div class="row form_element_header associated_auto_header callout">
|
|
6
6
|
<div class='medium-11 large-11 column' >
|
|
7
|
-
<%= "
|
|
7
|
+
<%= t("inline_forms.view.versions", count: entries.length) %>
|
|
8
8
|
</div>
|
|
9
9
|
<div class='medium-1 large-1 column'>
|
|
10
10
|
<%= close_versions_list_link(
|
|
@@ -32,16 +32,16 @@
|
|
|
32
32
|
|
|
33
33
|
</div>
|
|
34
34
|
<div class="small-1 column">
|
|
35
|
-
<strong
|
|
35
|
+
<strong><%= t("inline_forms.view.versions_event") %></strong>
|
|
36
36
|
</div>
|
|
37
37
|
<div class="small-2 column">
|
|
38
|
-
<strong
|
|
38
|
+
<strong><%= t("inline_forms.view.versions_when") %></strong>
|
|
39
39
|
</div>
|
|
40
40
|
<div class="small-2 column">
|
|
41
|
-
<strong
|
|
41
|
+
<strong><%= t("inline_forms.view.versions_done_by") %></strong>
|
|
42
42
|
</div>
|
|
43
43
|
<div class="small-6 column">
|
|
44
|
-
<strong
|
|
44
|
+
<strong><%= t("inline_forms.view.versions_changeset") %></strong>
|
|
45
45
|
</div>
|
|
46
46
|
</div>
|
|
47
47
|
<% entries.reverse.each do |entry| %>
|
|
@@ -92,17 +92,17 @@
|
|
|
92
92
|
<% end %>
|
|
93
93
|
</div>
|
|
94
94
|
<div class="small-1 column">
|
|
95
|
-
<%= version.event -%><% if entry[:kind] == :rich_text %> <em>(
|
|
95
|
+
<%= t("inline_forms.view.version_events.#{version.event}", default: version.event) -%><% if entry[:kind] == :rich_text %> <em>(<%= t("inline_forms.view.versions_rich_text") %>)</em><% end %>
|
|
96
96
|
</div>
|
|
97
97
|
<div class="small-2 column">
|
|
98
|
-
<%= version.created_at -%>
|
|
98
|
+
<%= l(version.created_at, format: :short) -%>
|
|
99
99
|
</div>
|
|
100
100
|
<div class="small-2 column">
|
|
101
101
|
<%= version_modified_by version.whodunnit -%>
|
|
102
102
|
</div>
|
|
103
103
|
<div class="small-6 column">
|
|
104
104
|
<% if version.changeset.nil? || version.changeset.empty? %>
|
|
105
|
-
<em
|
|
105
|
+
<em><%= t("inline_forms.view.versions_empty") %></em>
|
|
106
106
|
<% else %>
|
|
107
107
|
<% version.changeset.each do |attribute, value| %>
|
|
108
108
|
<% next if attribute == 'updated_at' %>
|
|
@@ -116,13 +116,15 @@
|
|
|
116
116
|
<th></th>
|
|
117
117
|
</tr>
|
|
118
118
|
<tr>
|
|
119
|
-
<td
|
|
120
|
-
<td
|
|
119
|
+
<td><%= t("inline_forms.view.versions_old_value") %></td>
|
|
120
|
+
<td><%= t("inline_forms.view.versions_new_value") %></td>
|
|
121
121
|
</tr>
|
|
122
122
|
<tr>
|
|
123
123
|
<% if entry[:kind] == :rich_text && attribute == 'body' %>
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
<%# Rich-text HTML as stored in the version: sanitized, never
|
|
125
|
+
raw (a body posted outside Trix may carry script). -%>
|
|
126
|
+
<td><%= sanitize value[0].to_s %></td>
|
|
127
|
+
<td><%= sanitize value[1].to_s %></td>
|
|
126
128
|
<% else %>
|
|
127
129
|
<td><%= value[0] %></td>
|
|
128
130
|
<td><%= value[1] %></td>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html>
|
|
2
|
+
<html lang="<%= I18n.locale %>">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
<%= stylesheet_link_tag "trix" %>
|
|
12
12
|
<%= inline_forms_user_color_overrides_style %>
|
|
13
13
|
<%= csrf_meta_tags %>
|
|
14
|
+
<%# Publishes the CSP nonce (nothing without a nonce generator) for the %>
|
|
15
|
+
<%# scripts Turbo inserts. %>
|
|
16
|
+
<%= csp_meta_tag %>
|
|
14
17
|
</head>
|
|
15
18
|
|
|
16
19
|
<body class="<%= inline_forms_body_theme_class %>">
|
|
@@ -19,10 +22,14 @@
|
|
|
19
22
|
<%# Turbo (Hotwire) is loaded as an ES module so it does not collide with %>
|
|
20
23
|
<%# the classic-script Sprockets bundle above. Drive uses the Turbo default %>
|
|
21
24
|
<%# (enabled). %>
|
|
22
|
-
|
|
25
|
+
<%# The nonce: hosts run a CSP with script-src 'nonce-…' (nil, so no %>
|
|
26
|
+
<%# attribute, without a nonce generator). data-turbo-eval="false": Turbo %>
|
|
27
|
+
<%# Drive must not re-run it on visits (Turbo is loaded; a re-inserted %>
|
|
28
|
+
<%# copy would carry a newer nonce the page's policy rejects). %>
|
|
29
|
+
<%= tag.script type: "module", nonce: content_security_policy_nonce, data: { turbo_eval: false } do %>
|
|
23
30
|
import { Turbo } from "<%= asset_path('turbo.min.js') %>"
|
|
24
31
|
window.Turbo = Turbo
|
|
25
|
-
|
|
32
|
+
<% end %>
|
|
26
33
|
<%# Trix 2 (vendored; no CDN). Self-registers its custom elements on load. %>
|
|
27
34
|
<%= javascript_include_tag "trix.min" %>
|
|
28
35
|
</body>
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
<%= stylesheet_link_tag "trix" %>
|
|
15
15
|
<%= inline_forms_user_color_overrides_style %>
|
|
16
16
|
<%= csrf_meta_tags %>
|
|
17
|
+
<%# Publishes the CSP nonce (nothing without a nonce generator) for the %>
|
|
18
|
+
<%# scripts Turbo inserts. %>
|
|
19
|
+
<%= csp_meta_tag %>
|
|
17
20
|
</head>
|
|
18
21
|
|
|
19
22
|
<body class="<%= inline_forms_body_theme_class %>">
|
|
@@ -40,10 +43,14 @@
|
|
|
40
43
|
<%# Turbo (Hotwire) is loaded as an ES module so it does not collide with %>
|
|
41
44
|
<%# the classic-script Sprockets bundle above. Drive stays at the default %>
|
|
42
45
|
<%# (enabled); inline interactions use `<turbo-frame>` + HTML responses. %>
|
|
43
|
-
|
|
46
|
+
<%# The nonce: hosts run a CSP with script-src 'nonce-…' (nil, so no %>
|
|
47
|
+
<%# attribute, without a nonce generator). data-turbo-eval="false": Turbo %>
|
|
48
|
+
<%# Drive must not re-run it on visits (Turbo is loaded; a re-inserted %>
|
|
49
|
+
<%# copy would carry a newer nonce the page's policy rejects). %>
|
|
50
|
+
<%= tag.script type: "module", nonce: content_security_policy_nonce, data: { turbo_eval: false } do %>
|
|
44
51
|
import { Turbo } from "<%= asset_path('turbo.min.js') %>"
|
|
45
52
|
window.Turbo = Turbo
|
|
46
|
-
|
|
53
|
+
<% end %>
|
|
47
54
|
<%# Trix 2 (vendored; no CDN). Self-registers its custom elements on load. %>
|
|
48
55
|
<%= javascript_include_tag "trix.min" %>
|
|
49
56
|
</body>
|
|
@@ -9,7 +9,9 @@ module InlineForms
|
|
|
9
9
|
o = object.send(attribute)
|
|
10
10
|
msg = o.to_s
|
|
11
11
|
if o.send(:present?)
|
|
12
|
-
|
|
12
|
+
# The stored file name and url are data: escape them before the
|
|
13
|
+
# string is marked html_safe.
|
|
14
|
+
msg = "replace | <a href='#{ERB::Util.h(o.send(:url))}'>#{ERB::Util.h(o.send(:path).gsub(/^.*\//,''))}</a>".html_safe
|
|
13
15
|
end
|
|
14
16
|
link_to_inline_edit object, attribute, msg, from_callee: __callee__
|
|
15
17
|
end
|
|
@@ -11,7 +11,9 @@ module InlineForms
|
|
|
11
11
|
out = "<div class='row #{cycle('odd', 'even')}'>--</div>" if object.send(attribute).empty?
|
|
12
12
|
object.send(attribute).sort.each do | item |
|
|
13
13
|
out << "<div class='row #{cycle('odd', 'even')}'>"
|
|
14
|
-
|
|
14
|
+
# _presentation is model data (e.g. a User's name listed on a Role):
|
|
15
|
+
# escape it, the whole string is marked html_safe below.
|
|
16
|
+
out << ERB::Util.h(item._presentation)
|
|
15
17
|
out << '</div>'
|
|
16
18
|
end
|
|
17
19
|
out.html_safe
|
data/lib/inline_forms/version.rb
CHANGED
|
@@ -14,6 +14,7 @@ en:
|
|
|
14
14
|
sign_up: sign up
|
|
15
15
|
login_with_email_and_password: login with email and password
|
|
16
16
|
sign_in: sign in
|
|
17
|
+
login: login
|
|
17
18
|
links:
|
|
18
19
|
no_unlock_instructions: did you not receive unlock instructions?
|
|
19
20
|
sign_up: sign up
|
|
@@ -31,6 +32,20 @@ en:
|
|
|
31
32
|
trash: trash
|
|
32
33
|
list_versions: List versions
|
|
33
34
|
close_versions_list: Close versions list
|
|
35
|
+
undelete: undelete
|
|
36
|
+
versions: Versions (%{count})
|
|
37
|
+
versions_event: Event
|
|
38
|
+
versions_when: When
|
|
39
|
+
versions_done_by: Done by
|
|
40
|
+
versions_changeset: Changeset
|
|
41
|
+
versions_old_value: old value
|
|
42
|
+
versions_new_value: new value
|
|
43
|
+
versions_empty: empty
|
|
44
|
+
versions_rich_text: rich text
|
|
45
|
+
version_events:
|
|
46
|
+
create: create
|
|
47
|
+
update: update
|
|
48
|
+
destroy: destroy
|
|
34
49
|
files:
|
|
35
50
|
download: download
|
|
36
51
|
replace: replace file
|
|
@@ -116,7 +131,13 @@ en:
|
|
|
116
131
|
expired: expired
|
|
117
132
|
pending_migration:
|
|
118
133
|
notice: "pending migration \u2014 run rails db:migrate"
|
|
119
|
-
|
|
134
|
+
# The create flash (InlineFormsController#create). Was misspelled
|
|
135
|
+
# `succes`, so t("success") was missing in every app.
|
|
136
|
+
success: "%{message} created."
|
|
137
|
+
# The engine header (inline_forms/_header): the More menu and logout.
|
|
138
|
+
common:
|
|
139
|
+
more: more...
|
|
140
|
+
logout: logout
|
|
120
141
|
activerecord:
|
|
121
142
|
models:
|
|
122
143
|
language: language
|
|
@@ -14,6 +14,7 @@ nl:
|
|
|
14
14
|
sign_up: inschrijven
|
|
15
15
|
login_with_email_and_password: aanmelden door email-adres en wachtwoord in te voeren.
|
|
16
16
|
sign_in: aanmelden
|
|
17
|
+
login: inloggen
|
|
17
18
|
links:
|
|
18
19
|
no_unlock_instructions: geen instructies ontvangen om blokkade op te heffen?
|
|
19
20
|
sign_up: inschrijven
|
|
@@ -27,7 +28,24 @@ nl:
|
|
|
27
28
|
close: sluiten
|
|
28
29
|
add_new: Maak een nieuw %{model}
|
|
29
30
|
undo: terughalen
|
|
31
|
+
restore: terugzetten
|
|
30
32
|
trash: weggooien
|
|
33
|
+
undelete: terughalen
|
|
34
|
+
list_versions: versies tonen
|
|
35
|
+
close_versions_list: versies verbergen
|
|
36
|
+
versions: Versies (%{count})
|
|
37
|
+
versions_event: Gebeurtenis
|
|
38
|
+
versions_when: Wanneer
|
|
39
|
+
versions_done_by: Door
|
|
40
|
+
versions_changeset: Wijzigingen
|
|
41
|
+
versions_old_value: oude waarde
|
|
42
|
+
versions_new_value: nieuwe waarde
|
|
43
|
+
versions_empty: leeg
|
|
44
|
+
versions_rich_text: opgemaakte tekst
|
|
45
|
+
version_events:
|
|
46
|
+
create: aangemaakt
|
|
47
|
+
update: gewijzigd
|
|
48
|
+
destroy: verwijderd
|
|
31
49
|
files:
|
|
32
50
|
download: downloaden
|
|
33
51
|
replace: bestand vervangen
|
|
@@ -113,7 +131,13 @@ nl:
|
|
|
113
131
|
expired: verlopen
|
|
114
132
|
pending_migration:
|
|
115
133
|
notice: "migratie in afwachting \u2014 draai rails db:migrate"
|
|
116
|
-
|
|
134
|
+
# The create flash (InlineFormsController#create). Was misspelled
|
|
135
|
+
# `succes`, so t("success") was missing in every app.
|
|
136
|
+
success: "%{message} is aangemaakt."
|
|
137
|
+
# The engine header (inline_forms/_header): the More menu and logout.
|
|
138
|
+
common:
|
|
139
|
+
more: meer...
|
|
140
|
+
logout: uitloggen
|
|
117
141
|
activerecord:
|
|
118
142
|
models:
|
|
119
143
|
language: taal
|
|
@@ -17,6 +17,9 @@ require "action_view/railtie"
|
|
|
17
17
|
Bundler.require(*Rails.groups)
|
|
18
18
|
|
|
19
19
|
require "inline_forms"
|
|
20
|
+
# Host apps list rails-i18n in their Gemfile (the installer writes it): the
|
|
21
|
+
# Rails strings for nl (time/date formats, ...) the gem's views localize with.
|
|
22
|
+
require "rails-i18n"
|
|
20
23
|
# The schema-GUI engine (separate gem, same repo; on the load path via the
|
|
21
24
|
# Gemfile's test group). Its integration tests live in test/integration/.
|
|
22
25
|
require "inline_forms_schema_edit"
|
|
@@ -34,5 +37,13 @@ module Dummy
|
|
|
34
37
|
config.active_record.maintain_test_schema = false
|
|
35
38
|
# Raise in-place so integration failures carry the real backtrace.
|
|
36
39
|
config.action_dispatch.show_exceptions = :none
|
|
40
|
+
# A nonce-based CSP like StProject's (report-only): the layouts' inline
|
|
41
|
+
# script must carry the per-request nonce (test/integration/csp_nonce_test.rb).
|
|
42
|
+
config.content_security_policy do |policy|
|
|
43
|
+
policy.script_src :self
|
|
44
|
+
end
|
|
45
|
+
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
|
|
46
|
+
config.content_security_policy_nonce_directives = %w[script-src]
|
|
47
|
+
config.content_security_policy_report_only = true
|
|
37
48
|
end
|
|
38
49
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration_test_helper"
|
|
4
|
+
|
|
5
|
+
# Host apps run a CSP with script-src 'nonce-…' (StProject; the dummy app is
|
|
6
|
+
# configured the same way, report-only). Every inline <script> the gem's
|
|
7
|
+
# layouts render carries the request's nonce, and the nonce is published in
|
|
8
|
+
# a csp-nonce meta tag for the scripts Turbo inserts. The unused
|
|
9
|
+
# inline_forms/_flash partial (an inline jQuery <script>) is gone.
|
|
10
|
+
class CspNonceTest < InlineFormsIntegrationTestCase
|
|
11
|
+
def header_nonce
|
|
12
|
+
policy = response.headers["Content-Security-Policy-Report-Only"].to_s
|
|
13
|
+
policy[/'nonce-([^']+)'/, 1]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def inline_scripts(html)
|
|
17
|
+
html.scan(/<script\b[^>]*>/).reject { |tag| tag.include?(" src=") }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def assert_inline_scripts_nonced(html, nonce)
|
|
21
|
+
scripts = inline_scripts(html)
|
|
22
|
+
assert_predicate scripts, :any?, "the layout's Turbo module script"
|
|
23
|
+
scripts.each { |tag| assert_includes tag, %(nonce="#{nonce}"), tag }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
test "the inline_forms layout nonces its inline script and publishes the nonce" do
|
|
27
|
+
get widgets_path
|
|
28
|
+
|
|
29
|
+
assert_response :success
|
|
30
|
+
nonce = header_nonce
|
|
31
|
+
assert nonce, "precondition: the dummy sends a nonce-based CSP"
|
|
32
|
+
assert_inline_scripts_nonced(response.body, nonce)
|
|
33
|
+
assert_match %r{<script type="module" nonce="#{Regexp.escape(nonce)}" data-turbo-eval="false">\s*import \{ Turbo \}}, response.body
|
|
34
|
+
assert_includes response.body, %(<meta name="csp-nonce" content="#{nonce}" />)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
test "the application layout nonces its inline script too" do
|
|
38
|
+
get widgets_path
|
|
39
|
+
nonce = header_nonce
|
|
40
|
+
html = controller.render_to_string(template: "stats/show", layout: "application")
|
|
41
|
+
|
|
42
|
+
assert_inline_scripts_nonced(html, nonce)
|
|
43
|
+
assert_includes html, %(<meta name="csp-nonce" content="#{nonce}" />)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
test "the unused inline_forms/_flash partial with its inline script is gone" do
|
|
47
|
+
get widgets_path
|
|
48
|
+
refute controller.lookup_context.exists?("flash", [ "inline_forms" ], true)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -299,6 +299,44 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
|
|
|
299
299
|
assert_nil @doc.reload.data
|
|
300
300
|
end
|
|
301
301
|
|
|
302
|
+
# The stored SHA-256 is checked before bytes go back into a slot: a
|
|
303
|
+
# tampered (or corrupted) trash row is refused, nothing is written, and the
|
|
304
|
+
# row stays in the trash.
|
|
305
|
+
test "restore refuses a trash row whose bytes do not match its checksum" do
|
|
306
|
+
with_file
|
|
307
|
+
entry = @doc.inline_forms_remove_file!(:filename, by: 1)
|
|
308
|
+
InlineForms::TrashedFile.where(id: entry.id).update_all(data: OTHER)
|
|
309
|
+
|
|
310
|
+
post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame),
|
|
311
|
+
headers: stream_headers(@frame)
|
|
312
|
+
|
|
313
|
+
assert_response :success
|
|
314
|
+
assert_includes response.body, "can no longer be restored"
|
|
315
|
+
@doc.reload
|
|
316
|
+
assert_nil @doc.filename
|
|
317
|
+
assert_nil @doc.data
|
|
318
|
+
assert entry.reload.trashed?, "not marked restored"
|
|
319
|
+
assert_equal OTHER, entry.file_data, "the row is left as it is, for a look"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
test "restore! raises and logs on a checksum mismatch, writing nothing" do
|
|
323
|
+
with_file
|
|
324
|
+
entry = @doc.inline_forms_remove_file!(:filename, by: 1)
|
|
325
|
+
InlineForms::TrashedFile.where(id: entry.id).update_all(checksum: Digest::SHA256.hexdigest("something else"))
|
|
326
|
+
log = StringIO.new
|
|
327
|
+
original_logger = Rails.logger
|
|
328
|
+
Rails.logger = ActiveSupport::Logger.new(log)
|
|
329
|
+
|
|
330
|
+
assert_raises(InlineForms::TrashedFile::NotRestorable) { entry.reload.restore!(by: 1) }
|
|
331
|
+
|
|
332
|
+
assert_includes log.string, "checksum mismatch entry=#{entry.id}"
|
|
333
|
+
assert_nil @doc.reload.data
|
|
334
|
+
assert entry.reload.trashed?
|
|
335
|
+
assert_equal 0, trash.where(reason: "replaced").count
|
|
336
|
+
ensure
|
|
337
|
+
Rails.logger = original_logger
|
|
338
|
+
end
|
|
339
|
+
|
|
302
340
|
test "a trash entry of another record is not found" do
|
|
303
341
|
other = Document.create!(title: "Dossier B", filename: upload(PLAN))
|
|
304
342
|
entry = other.inline_forms_remove_file!(:filename, by: 1)
|
|
@@ -414,6 +452,43 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
|
|
|
414
452
|
assert_includes response.body, "restorable until"
|
|
415
453
|
end
|
|
416
454
|
|
|
455
|
+
# FileSlotsConcern has no per-tab attribute list, so the field it
|
|
456
|
+
# re-renders used to build its links' slots= from the model's full list:
|
|
457
|
+
# after a restore on one tab, the next action's panel (and the versions
|
|
458
|
+
# panel) listed the other tabs' slots. The page's own slots param now
|
|
459
|
+
# carries through.
|
|
460
|
+
test "a file action re-renders with the page's slots only" do
|
|
461
|
+
with_file
|
|
462
|
+
@doc.update!(plan_filename: upload(OTHER, "other-tab.pdf"))
|
|
463
|
+
@doc.inline_forms_remove_file!(:plan_filename, by: 1)
|
|
464
|
+
entry = @doc.inline_forms_remove_file!(:filename, by: 1)
|
|
465
|
+
|
|
466
|
+
post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame,
|
|
467
|
+
slots: "filename", panel: "open"),
|
|
468
|
+
headers: stream_headers(@frame)
|
|
469
|
+
|
|
470
|
+
assert_response :success
|
|
471
|
+
assert_equal PLAN, @doc.reload.data
|
|
472
|
+
remove_link = response.body[%r{href="([^"]*/remove_file\?[^"]*)"}, 1]
|
|
473
|
+
assert remove_link, "the restored field offers remove"
|
|
474
|
+
assert_equal "filename", Rack::Utils.parse_query(URI.parse(CGI.unescapeHTML(remove_link)).query)["slots"]
|
|
475
|
+
refute_includes response.body, "other-tab.pdf", "neither the trash panel nor the versions panel lists the other slot"
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
test "a file action's slots param is reduced to declared, listed slots" do
|
|
479
|
+
with_file
|
|
480
|
+
@doc.update!(plan_filename: upload(OTHER, "other-tab.pdf"))
|
|
481
|
+
@doc.inline_forms_remove_file!(:plan_filename, by: 1)
|
|
482
|
+
entry = @doc.inline_forms_remove_file!(:filename, by: 1)
|
|
483
|
+
|
|
484
|
+
post restore_file_document_path(@doc, attribute: "filename", trash_id: entry.id, update: @frame,
|
|
485
|
+
slots: "filename,title,data", panel: "open"),
|
|
486
|
+
headers: stream_headers(@frame)
|
|
487
|
+
|
|
488
|
+
remove_link = response.body[%r{href="([^"]*/remove_file\?[^"]*)"}, 1]
|
|
489
|
+
assert_equal "filename", Rack::Utils.parse_query(URI.parse(CGI.unescapeHTML(remove_link)).query)["slots"]
|
|
490
|
+
end
|
|
491
|
+
|
|
417
492
|
test "the panel needs :read_file_trash" do
|
|
418
493
|
Ability.restrictions = -> { cannot :read_file_trash, Document }
|
|
419
494
|
panel = "document_#{@doc.id}_file_trash"
|
|
@@ -456,6 +531,22 @@ class FileSlotsTest < InlineFormsIntegrationTestCase
|
|
|
456
531
|
assert_nil restored.data
|
|
457
532
|
end
|
|
458
533
|
|
|
534
|
+
test "undoing a destroy refuses a tampered trash row: nothing is restored" do
|
|
535
|
+
with_file
|
|
536
|
+
row = "document_#{@doc.id}"
|
|
537
|
+
delete document_path(@doc, update: row), headers: frame_headers(row)
|
|
538
|
+
entry = trash.where(reason: "record_destroyed").sole
|
|
539
|
+
InlineForms::TrashedFile.where(id: entry.id).update_all(data: OTHER)
|
|
540
|
+
|
|
541
|
+
version = PaperTrail::Version.where(item_type: "Document", item_id: @doc.id, event: "destroy").last
|
|
542
|
+
post revert_document_path(version.id, update: "documents_list"), headers: stream_headers("documents_list")
|
|
543
|
+
|
|
544
|
+
assert_response :unprocessable_entity
|
|
545
|
+
refute Document.exists?(@doc.id), "the revert rolled back"
|
|
546
|
+
assert entry.reload.trashed?
|
|
547
|
+
assert_equal OTHER, entry.file_data
|
|
548
|
+
end
|
|
549
|
+
|
|
459
550
|
test "reverting an update never touches a slot, even from an old version carrying a filename" do
|
|
460
551
|
with_file
|
|
461
552
|
@doc.update!(title: "Renamed")
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration_test_helper"
|
|
4
|
+
|
|
5
|
+
# Request params that end up in a list's markup.
|
|
6
|
+
#
|
|
7
|
+
# * parent_class / parent_id: _list built the nested frame as
|
|
8
|
+
# `raw "<turbo-frame id=\"#{update_span}\" …>"` with update_span made from
|
|
9
|
+
# params[:parent_id], and constantized params[:parent_class]; nothing found
|
|
10
|
+
# the parent before the render, so a crafted parent_id was reflected into
|
|
11
|
+
# the page. The controller now refuses malformed parent params (400) in
|
|
12
|
+
# index/new/create/show, and the frame is built with tag.turbo_frame.
|
|
13
|
+
# * pagination: the page links carry every filter of the query string, not
|
|
14
|
+
# just `search`.
|
|
15
|
+
#
|
|
16
|
+
# Part and DailyReport belong to Machine; Widget is a top-level list
|
|
17
|
+
# (per_page 7, see ApplicationRecord).
|
|
18
|
+
class ListParamsTest < InlineFormsIntegrationTestCase
|
|
19
|
+
PAYLOAD = %("><img src=x onerror=alert(1)>)
|
|
20
|
+
|
|
21
|
+
setup do
|
|
22
|
+
@machine = Machine.create!(name: "Press")
|
|
23
|
+
@part = Part.create!(name: "Gear", machine: @machine)
|
|
24
|
+
@list_frame = "machine_#{@machine.id}_parts_list"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def assert_refused_without_payload
|
|
28
|
+
assert_response :bad_request
|
|
29
|
+
assert controller.instance_variable_defined?(:@_authorized),
|
|
30
|
+
"the refusal runs after authorization (check_authorization stays satisfied)"
|
|
31
|
+
refute_includes response.body, "<img"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def nested(parent_id: @machine.id, parent_class: "Machine", update: @list_frame)
|
|
35
|
+
{ parent_class: parent_class, parent_id: parent_id, update: update, ul_needed: 1 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# ---- malformed parent params --------------------------------------------
|
|
39
|
+
|
|
40
|
+
test "a crafted parent_id on the nested list is a 400 and never rendered" do
|
|
41
|
+
get parts_path(nested(parent_id: PAYLOAD, update: "x")), headers: frame_headers("x")
|
|
42
|
+
assert_refused_without_payload
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
test "a crafted parent_id is refused on a full-page visit too" do
|
|
46
|
+
get parts_path(nested(parent_id: PAYLOAD, update: "x"))
|
|
47
|
+
assert_refused_without_payload
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test "a parent_class that is not an association of the model is a 400" do
|
|
51
|
+
%w[Widget Kernel machine Machines ActiveRecord::Base].each do |parent_class|
|
|
52
|
+
get parts_path(nested(parent_class: parent_class)), headers: frame_headers(@list_frame)
|
|
53
|
+
assert_response :bad_request, "parent_class=#{parent_class}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test "a non-integer parent_id is a 400, with or without parent_class" do
|
|
58
|
+
[ "1 OR 1=1", "-1", "1.5", "" ].each do |parent_id|
|
|
59
|
+
get parts_path(nested(parent_id: parent_id)), headers: frame_headers(@list_frame)
|
|
60
|
+
assert_response :bad_request, "parent_id=#{parent_id.inspect}"
|
|
61
|
+
end
|
|
62
|
+
get parts_path(parent_id: PAYLOAD, update: "x")
|
|
63
|
+
assert_refused_without_payload
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
test "new with crafted parent params is a 400" do
|
|
67
|
+
get new_part_path(nested(parent_id: PAYLOAD, update: "machine_#{@machine.id}_parts")),
|
|
68
|
+
headers: frame_headers("machine_#{@machine.id}_parts")
|
|
69
|
+
assert_refused_without_payload
|
|
70
|
+
|
|
71
|
+
get new_part_path(nested(parent_class: "Widget", update: "machine_#{@machine.id}_parts")),
|
|
72
|
+
headers: frame_headers("machine_#{@machine.id}_parts")
|
|
73
|
+
assert_response :bad_request
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
test "create with crafted parent params is a 400 and creates nothing" do
|
|
77
|
+
frame = "machine_#{@machine.id}_parts"
|
|
78
|
+
assert_no_difference -> { Part.count } do
|
|
79
|
+
post parts_path(nested(parent_id: PAYLOAD, update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
|
|
80
|
+
assert_refused_without_payload
|
|
81
|
+
post parts_path(nested(parent_class: "Widget", update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
|
|
82
|
+
assert_response :bad_request
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "show with a crafted parent_id is a 400" do
|
|
87
|
+
row = "machine_#{@machine.id}_part_#{@part.id}"
|
|
88
|
+
get part_path(@part, update: row, parent_class: "Machine", parent_id: PAYLOAD), headers: frame_headers(row)
|
|
89
|
+
assert_refused_without_payload
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ---- well-formed nested lists still work --------------------------------
|
|
93
|
+
|
|
94
|
+
test "a well-formed nested list renders its frame and rows" do
|
|
95
|
+
get parts_path(nested), headers: frame_headers(@list_frame)
|
|
96
|
+
|
|
97
|
+
assert_response :success
|
|
98
|
+
assert_includes response.body, %(<turbo-frame id="#{@list_frame}" class="list_container">)
|
|
99
|
+
assert_includes response.body, "Gear"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
test "well-formed nested new and create still work" do
|
|
103
|
+
frame = "machine_#{@machine.id}_parts"
|
|
104
|
+
get new_part_path(nested(update: frame)), headers: frame_headers(frame)
|
|
105
|
+
assert_response :success
|
|
106
|
+
|
|
107
|
+
assert_difference -> { @machine.parts.count }, 1 do
|
|
108
|
+
post parts_path(nested(update: frame)), params: { name: "Bolt" }, headers: frame_headers(frame)
|
|
109
|
+
end
|
|
110
|
+
assert_response :success
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
test "the nested list of a not_accessible_through_html? child still renders" do
|
|
114
|
+
DailyReport.create!(name: "Monday", machine: @machine)
|
|
115
|
+
frame = "machine_#{@machine.id}_daily_reports_list"
|
|
116
|
+
get daily_reports_path(parent_class: "Machine", parent_id: @machine.id, update: frame, ul_needed: 1),
|
|
117
|
+
headers: frame_headers(frame)
|
|
118
|
+
|
|
119
|
+
assert_response :success
|
|
120
|
+
assert_includes response.body, "Monday"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
test "the list frame id is attribute-escaped by the partial itself" do
|
|
124
|
+
html = PartsController.render(
|
|
125
|
+
partial: "inline_forms/list",
|
|
126
|
+
assigns: { Klass: Part, parent_class: "Machine", parent_id: PAYLOAD, ul_needed: "1",
|
|
127
|
+
objects: Part.where(id: @part.id).paginate(page: 1) }
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
refute_includes html, "<img"
|
|
131
|
+
assert_includes html, %(<turbo-frame id="machine_"><img src=x onerror=alert(1)>_parts_list" class="list_container">)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# ---- pagination keeps the filters ---------------------------------------
|
|
135
|
+
|
|
136
|
+
test "page links of a filtered list carry every filter" do
|
|
137
|
+
9.times { |i| Widget.create!(name: "Match #{i}") }
|
|
138
|
+
Widget.create!(name: "Other")
|
|
139
|
+
|
|
140
|
+
get widgets_path(search: "Match", sorteer: "name", first_name: "An", group: { id: 3 }, status: { id: 4 },
|
|
141
|
+
update: "widgets_list"),
|
|
142
|
+
headers: frame_headers("widgets_list")
|
|
143
|
+
|
|
144
|
+
assert_response :success
|
|
145
|
+
page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
|
|
146
|
+
assert page_link, "a link to page 2"
|
|
147
|
+
query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(page_link)).query)
|
|
148
|
+
assert_equal "Match", query["search"]
|
|
149
|
+
assert_equal "name", query["sorteer"]
|
|
150
|
+
assert_equal "An", query["first_name"]
|
|
151
|
+
assert_equal({ "id" => "3" }, query["group"])
|
|
152
|
+
assert_equal({ "id" => "4" }, query["status"])
|
|
153
|
+
assert_equal "widgets_list", query["update"]
|
|
154
|
+
assert_equal "2", query["page"]
|
|
155
|
+
|
|
156
|
+
get CGI.unescapeHTML(page_link), headers: frame_headers("widgets_list")
|
|
157
|
+
assert_response :success
|
|
158
|
+
back = response.body[/href="([^"]*page=1[^"]*)"/, 1]
|
|
159
|
+
assert back, "a link back to page 1"
|
|
160
|
+
back_query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(back)).query)
|
|
161
|
+
assert_equal "Match", back_query["search"]
|
|
162
|
+
assert_equal({ "id" => "3" }, back_query["group"])
|
|
163
|
+
refute_includes response.body, "Other"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
test "page links of a list rendered after create keep the request's filters" do
|
|
167
|
+
9.times { |i| Machine.create!(name: "Match #{i}") }
|
|
168
|
+
|
|
169
|
+
post machines_path(update: "machines_list", search: "Match", sorteer: "name"),
|
|
170
|
+
params: { name: "Match 9" }, headers: frame_headers("machines_list")
|
|
171
|
+
|
|
172
|
+
assert_response :success
|
|
173
|
+
page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
|
|
174
|
+
assert page_link, "a link to page 2"
|
|
175
|
+
query = Rack::Utils.parse_nested_query(URI.parse(CGI.unescapeHTML(page_link)).query)
|
|
176
|
+
assert_equal "Match", query["search"]
|
|
177
|
+
assert_equal "name", query["sorteer"]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# url_for options in the query string must not shape the page links.
|
|
181
|
+
# will_paginate leaves out script_name / original_script_name; merging
|
|
182
|
+
# request.query_parameters into the links bypassed that, so the payload
|
|
183
|
+
# became the start of every page link's href, and controller= raised.
|
|
184
|
+
test "url options in the query string never reach the page links" do
|
|
185
|
+
9.times { |i| Widget.create!(name: "W #{i}") }
|
|
186
|
+
|
|
187
|
+
%w[script_name original_script_name].each do |option|
|
|
188
|
+
get "/widgets?update=widgets_list&#{option}=javascript:alert(1)//", headers: frame_headers("widgets_list")
|
|
189
|
+
assert_response :success
|
|
190
|
+
page_link = response.body[/href="([^"]*page=2[^"]*)"/, 1]
|
|
191
|
+
assert page_link, "a link to page 2"
|
|
192
|
+
assert page_link.start_with?("/widgets?"), "#{option} shaped the page link: #{page_link}"
|
|
193
|
+
refute_includes response.body, "javascript:alert"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
get "/widgets?update=widgets_list&controller=widgets_x", headers: frame_headers("widgets_list")
|
|
197
|
+
assert_response :success
|
|
198
|
+
assert_match %r{href="/widgets\?[^"]*page=2}, response.body
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration_test_helper"
|
|
4
|
+
|
|
5
|
+
# The gem's own strings resolve in en and nl, also under
|
|
6
|
+
# raise_on_missing_translations (StProject switched to :nl with :strict):
|
|
7
|
+
# the create flash (`success`, misspelled `succes` in both locale files), the
|
|
8
|
+
# versions panel (was hard-coded English), the nl view keys that were missing
|
|
9
|
+
# (restore, list_versions, close_versions_list, undelete), localized version
|
|
10
|
+
# timestamps, the Devise layout's lang and the sign-in button.
|
|
11
|
+
class LocaleTest < InlineFormsIntegrationTestCase
|
|
12
|
+
KEYS = %w[
|
|
13
|
+
success
|
|
14
|
+
inline_forms.view.restore inline_forms.view.list_versions inline_forms.view.close_versions_list
|
|
15
|
+
inline_forms.view.undelete inline_forms.view.versions inline_forms.view.versions_event
|
|
16
|
+
inline_forms.view.versions_when inline_forms.view.versions_done_by inline_forms.view.versions_changeset
|
|
17
|
+
inline_forms.view.versions_old_value inline_forms.view.versions_new_value inline_forms.view.versions_empty
|
|
18
|
+
inline_forms.view.versions_rich_text inline_forms.view.version_events.create
|
|
19
|
+
inline_forms.view.version_events.update inline_forms.view.version_events.destroy
|
|
20
|
+
inline_forms.devise.login common.more common.logout
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
setup do
|
|
24
|
+
@machine = Machine.create!(name: "Press")
|
|
25
|
+
@machine.update!(name: "Press 2")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
teardown do
|
|
29
|
+
I18n.backend.reload!
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Every translation the request looks up must exist: a missing one raises,
|
|
33
|
+
# in the views and in the controller (create's flash).
|
|
34
|
+
def strictly(locale)
|
|
35
|
+
original_view = ActionView::Helpers::TranslationHelper.raise_on_missing_translations
|
|
36
|
+
original_handler = I18n.exception_handler
|
|
37
|
+
original_default = I18n.default_locale
|
|
38
|
+
ActionView::Helpers::TranslationHelper.raise_on_missing_translations = true
|
|
39
|
+
I18n.exception_handler = ->(exception, *) { raise exception.respond_to?(:to_exception) ? exception.to_exception : exception }
|
|
40
|
+
# with_locale first: it restores the locale it found, so the thread's
|
|
41
|
+
# locale must not be read after default_locale changed (it would then
|
|
42
|
+
# restore :nl for every later test).
|
|
43
|
+
I18n.with_locale(locale) do
|
|
44
|
+
I18n.default_locale = locale
|
|
45
|
+
yield
|
|
46
|
+
end
|
|
47
|
+
ensure
|
|
48
|
+
I18n.default_locale = original_default
|
|
49
|
+
ActionView::Helpers::TranslationHelper.raise_on_missing_translations = original_view
|
|
50
|
+
I18n.exception_handler = original_handler
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
test "the gem's keys exist in en and nl, and the misspelled succes is gone" do
|
|
54
|
+
%i[en nl].each do |locale|
|
|
55
|
+
KEYS.each { |key| assert I18n.exists?(key, locale), "#{locale}.#{key}" }
|
|
56
|
+
refute I18n.exists?("succes", locale), "#{locale}.succes"
|
|
57
|
+
end
|
|
58
|
+
assert_equal "Machine created.", I18n.t("success", message: "Machine", locale: :en)
|
|
59
|
+
assert_equal "Machine is aangemaakt.", I18n.t("success", message: "Machine", locale: :nl)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "create's success flash translates in nl" do
|
|
63
|
+
# The model top bar shows t(controller_name): host-supplied, like
|
|
64
|
+
# StProject's `clients: cliënten`.
|
|
65
|
+
I18n.backend.store_translations(:nl, machines: "machines")
|
|
66
|
+
strictly(:nl) do
|
|
67
|
+
post machines_path(update: "machines_list"), params: { name: "Lathe" }, headers: frame_headers("machines_list")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
assert_response :success
|
|
71
|
+
assert_equal "Machine is aangemaakt.", controller.flash[:success]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
test "the versions panel and list are translated, with localized timestamps" do
|
|
75
|
+
row = "machine_#{@machine.id}"
|
|
76
|
+
strictly(:nl) do
|
|
77
|
+
get machine_path(@machine, update: row), headers: frame_headers(row)
|
|
78
|
+
assert_response :success
|
|
79
|
+
assert_includes response.body, "Versies (2)"
|
|
80
|
+
|
|
81
|
+
get list_versions_machine_path(@machine, update: row), headers: frame_headers(row)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
assert_response :success
|
|
85
|
+
body = response.body
|
|
86
|
+
%w[Versies Gebeurtenis Wanneer Door Wijzigingen aangemaakt gewijzigd terugzetten].each do |text|
|
|
87
|
+
assert_includes body, text
|
|
88
|
+
end
|
|
89
|
+
refute_includes body, "<strong>Event</strong>"
|
|
90
|
+
version = @machine.versions.last
|
|
91
|
+
assert_includes body, ERB::Util.h(I18n.l(version.created_at, format: :short, locale: :nl))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
test "the versions list in en keeps its English labels" do
|
|
95
|
+
row = "machine_#{@machine.id}"
|
|
96
|
+
strictly(:en) do
|
|
97
|
+
get list_versions_machine_path(@machine, update: row), headers: frame_headers(row)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
assert_response :success
|
|
101
|
+
assert_includes response.body, "Versions (2)"
|
|
102
|
+
assert_includes response.body, "<strong>Event</strong>"
|
|
103
|
+
assert_includes response.body, "old value"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
test "the devise and application layouts declare the page language" do
|
|
107
|
+
get widgets_path
|
|
108
|
+
I18n.with_locale(:nl) do
|
|
109
|
+
assert_includes controller.render_to_string(template: "stats/show", layout: "devise"), %(<html lang="nl">)
|
|
110
|
+
assert_includes controller.render_to_string(template: "stats/show", layout: "application"), %(<html lang="nl">)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../integration_test_helper"
|
|
4
|
+
|
|
5
|
+
# Model data that a helper concatenates into a string it marks html_safe must
|
|
6
|
+
# be escaped first. info_list_show did `out << item._presentation`, so a
|
|
7
|
+
# _presentation built from user input (StProject: User#name listed on a Role)
|
|
8
|
+
# ran as markup on every page that showed the list (stored XSS). Same audit:
|
|
9
|
+
# file_field_show (stored file name / url) and the rich-text changeset in the
|
|
10
|
+
# versions list (was `raw`).
|
|
11
|
+
class PresentationEscapingTest < InlineFormsIntegrationTestCase
|
|
12
|
+
MARKUP = "<img src=x onerror=alert(1)>"
|
|
13
|
+
ESCAPED = "<img src=x onerror=alert(1)>"
|
|
14
|
+
|
|
15
|
+
# A view context of a real request: helpers, can?, url helpers.
|
|
16
|
+
def view
|
|
17
|
+
@view ||= begin
|
|
18
|
+
get widgets_path
|
|
19
|
+
controller.view_context
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
test "info_list escapes each item's _presentation" do
|
|
24
|
+
machine = Machine.create!(name: "Press")
|
|
25
|
+
Part.create!(name: MARKUP, machine: machine)
|
|
26
|
+
Part.create!(name: "Gear", machine: machine)
|
|
27
|
+
|
|
28
|
+
html = view.info_list_show(machine, :parts)
|
|
29
|
+
|
|
30
|
+
refute_includes html, "<img"
|
|
31
|
+
assert_includes html, ESCAPED
|
|
32
|
+
assert_includes html, "Gear"
|
|
33
|
+
assert_includes html, "<div class='row", "the row markup itself is kept"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
test "file_field escapes the stored file name and url" do
|
|
37
|
+
widget = Widget.create!(name: "Manual")
|
|
38
|
+
stored = Struct.new(:url, :path) do
|
|
39
|
+
def present? = true
|
|
40
|
+
def to_s = url
|
|
41
|
+
end.new(%(/uploads/x'><img src=x onerror=alert(1)>.pdf), %(/uploads/#{MARKUP}.pdf))
|
|
42
|
+
widget.define_singleton_method(:manual) { stored }
|
|
43
|
+
|
|
44
|
+
html = view.file_field_show(widget, :manual)
|
|
45
|
+
|
|
46
|
+
refute_includes html, "<img"
|
|
47
|
+
assert_includes html, ESCAPED
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
test "dropdown_with_other and the list rows escape _presentation" do
|
|
51
|
+
kind = Kind.create!(name: MARKUP)
|
|
52
|
+
widget = Widget.create!(name: MARKUP, kind: kind)
|
|
53
|
+
|
|
54
|
+
refute_includes view.dropdown_with_other_show(widget, :kind), "<img"
|
|
55
|
+
|
|
56
|
+
get widgets_path(update: "widgets_list"), headers: frame_headers("widgets_list")
|
|
57
|
+
assert_response :success
|
|
58
|
+
refute_includes response.body, "<img"
|
|
59
|
+
assert_includes response.body, ESCAPED
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
test "a rich-text changeset in the versions list is sanitized, not raw" do
|
|
63
|
+
machine = Machine.create!(name: "Press")
|
|
64
|
+
version = Struct.new(:id, :event, :changeset, :created_at, :whodunnit) do
|
|
65
|
+
def to_param = id.to_s
|
|
66
|
+
end.new(1, "update", { "body" => [ "<p>old</p><script>alert(1)</script>", "<p>new</p>#{MARKUP}" ] }, Time.current, nil)
|
|
67
|
+
context = view
|
|
68
|
+
context.instance_variable_set(:@object, machine)
|
|
69
|
+
context.define_singleton_method(:inline_forms_history_for) do |_object|
|
|
70
|
+
[ { version: version, kind: :rich_text, rich_text_name: "description", at: version.created_at } ]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
html = context.render(partial: "inline_forms/versions_list")
|
|
74
|
+
|
|
75
|
+
refute_includes html, "<script"
|
|
76
|
+
refute_includes html, "onerror"
|
|
77
|
+
assert_includes html, "<p>old</p>"
|
|
78
|
+
assert_includes html, "<p>new</p>"
|
|
79
|
+
end
|
|
80
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inline_forms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 8.1.
|
|
4
|
+
version: 8.1.57
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ace Suares
|
|
@@ -153,7 +153,6 @@ files:
|
|
|
153
153
|
- app/views/inline_forms/_close.html.erb
|
|
154
154
|
- app/views/inline_forms/_edit.html.erb
|
|
155
155
|
- app/views/inline_forms/_file_slot.html.erb
|
|
156
|
-
- app/views/inline_forms/_flash.html.erb
|
|
157
156
|
- app/views/inline_forms/_generic_search.html.erb
|
|
158
157
|
- app/views/inline_forms/_header.html.erb
|
|
159
158
|
- app/views/inline_forms/_list.html.erb
|
|
@@ -314,16 +313,20 @@ files:
|
|
|
314
313
|
- test/inline_edit_polymorphic_path_test.rb
|
|
315
314
|
- test/inline_forms_addto_generator_test.rb
|
|
316
315
|
- test/inline_forms_generator_test.rb
|
|
316
|
+
- test/integration/csp_nonce_test.rb
|
|
317
317
|
- test/integration/field_authorization_test.rb
|
|
318
318
|
- test/integration/file_slots_test.rb
|
|
319
319
|
- test/integration/html_refusal_test.rb
|
|
320
320
|
- test/integration/inline_forms_crud_test.rb
|
|
321
321
|
- test/integration/layout_title_test.rb
|
|
322
322
|
- test/integration/legacy_elements_test.rb
|
|
323
|
+
- test/integration/list_params_test.rb
|
|
324
|
+
- test/integration/locale_test.rb
|
|
323
325
|
- test/integration/model_top_bar_test.rb
|
|
324
326
|
- test/integration/native_inputs_test.rb
|
|
325
327
|
- test/integration/open_after_create_test.rb
|
|
326
328
|
- test/integration/pending_migration_gate_test.rb
|
|
329
|
+
- test/integration/presentation_escaping_test.rb
|
|
327
330
|
- test/integration/revert_skipped_columns_test.rb
|
|
328
331
|
- test/integration/schema_batch_pipeline_test.rb
|
|
329
332
|
- test/integration/schema_edit_test.rb
|
|
@@ -398,16 +401,20 @@ test_files:
|
|
|
398
401
|
- test/inline_edit_polymorphic_path_test.rb
|
|
399
402
|
- test/inline_forms_addto_generator_test.rb
|
|
400
403
|
- test/inline_forms_generator_test.rb
|
|
404
|
+
- test/integration/csp_nonce_test.rb
|
|
401
405
|
- test/integration/field_authorization_test.rb
|
|
402
406
|
- test/integration/file_slots_test.rb
|
|
403
407
|
- test/integration/html_refusal_test.rb
|
|
404
408
|
- test/integration/inline_forms_crud_test.rb
|
|
405
409
|
- test/integration/layout_title_test.rb
|
|
406
410
|
- test/integration/legacy_elements_test.rb
|
|
411
|
+
- test/integration/list_params_test.rb
|
|
412
|
+
- test/integration/locale_test.rb
|
|
407
413
|
- test/integration/model_top_bar_test.rb
|
|
408
414
|
- test/integration/native_inputs_test.rb
|
|
409
415
|
- test/integration/open_after_create_test.rb
|
|
410
416
|
- test/integration/pending_migration_gate_test.rb
|
|
417
|
+
- test/integration/presentation_escaping_test.rb
|
|
411
418
|
- test/integration/revert_skipped_columns_test.rb
|
|
412
419
|
- test/integration/schema_batch_pipeline_test.rb
|
|
413
420
|
- test/integration/schema_edit_test.rb
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
<% unless flash.empty? %>
|
|
2
|
-
<% identifier = Time.now.to_i.to_s %>
|
|
3
|
-
<div class="row" id="flash_<%= identifier %>">
|
|
4
|
-
<% flash.each do |key, value| %>
|
|
5
|
-
<%= value %>
|
|
6
|
-
<% end %>
|
|
7
|
-
<script>
|
|
8
|
-
$("#<%= "#{update_span}_auto_header" %>").ScrollTo();
|
|
9
|
-
$("#flash_<%= identifier %>").delay(1000).fadeToggle('2000');
|
|
10
|
-
</script>
|
|
11
|
-
</div>
|
|
12
|
-
<% end %>
|
|
13
|
-
|