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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ecd64e62be7265a5989cc6012533cdac80dbd62f82ab8b009c17bd4c12343dd
4
- data.tar.gz: fb677f7996a52a2a8d571f65264f22225a9ff9ed8c451657a2ac59c268d396d5
3
+ metadata.gz: 93e0cd0c2bc09c666bd81cf76779b780ab345f4a7e80f8a6535177d7921b150d
4
+ data.tar.gz: 02e8abc26971fe105a65b8bae7380d171e2635e1e51385c0849917e1a5365c16
5
5
  SHA512:
6
- metadata.gz: a5d4ed8bbb6569904df70ebbbd30b57c4847bb478088dfe70419363c58caf11d19dc6dfb5c67da2fbdaad8a85555c5b74b68847d19484f272d7c5fcbebcc30cf
7
- data.tar.gz: a5b41fd5b309e7fd73bec6c652609f775493c1b3efdf977d3aed1503c6e6656d4e8d7e695bac99ca751b914bb2a47be64bdb244233820253d2426e87240515ee
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
- @object.class.transaction do
384
- file_entries = InlineForms::StoredFiles.prepare_revert!(@object)
385
- @parent = persist_reverted_primary!(@object, @version)
386
- InlineForms::StoredFiles.finish_revert!(file_entries, by: inline_forms_current_user_id)
387
- restore_rich_texts_for_reverted_parent!(@parent)
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
- <%# List root is `<turbo-frame class="list_container">` (Step 4). See SCSS. %>
46
- <% if ul_needed -%>
47
- <%= raw "<turbo-frame id=\"#{update_span}\" class=\"list_container\">" -%>
48
- <% end -%>
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
- <%= raw "</turbo-frame>" -%>
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
- <%= "Versions (#{inline_forms_history_for(object).length})" %>
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(
@@ -23,7 +23,7 @@
23
23
  <%= t("inline_forms.files.event.#{entry[:file_event]}") %>
24
24
  </div>
25
25
  <div class="small-2 column">
26
- <%= entry[:at] %>
26
+ <%= l(entry[:at], format: :short) %>
27
27
  </div>
28
28
  <div class="small-2 column">
29
29
  <%= inline_forms_file_user_name(entry[:by]) %>
@@ -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
- <%= "Versions (#{entries.length})" %>
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
  &nbsp;
33
33
  </div>
34
34
  <div class="small-1 column">
35
- <strong>Event</strong>
35
+ <strong><%= t("inline_forms.view.versions_event") %></strong>
36
36
  </div>
37
37
  <div class="small-2 column">
38
- <strong>When</strong>
38
+ <strong><%= t("inline_forms.view.versions_when") %></strong>
39
39
  </div>
40
40
  <div class="small-2 column">
41
- <strong>Done by</strong>
41
+ <strong><%= t("inline_forms.view.versions_done_by") %></strong>
42
42
  </div>
43
43
  <div class="small-6 column">
44
- <strong>Changeset</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>(rich text)</em><% end %>
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>empty</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>old value</td>
120
- <td>new value</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
- <td><%= raw value[0] %></td>
125
- <td><%= raw value[1] %></td>
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
- <script type="module">
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
- </script>
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>
@@ -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" />
@@ -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
- <script type="module">
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
- </script>
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
- msg = "replace | <a href='#{o.send(:url)}'>#{o.send(:path).gsub(/^.*\//,'')}</a>".html_safe
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
- out << item._presentation
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
@@ -1,5 +1,5 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
 
3
3
  module InlineForms
4
- VERSION = "8.1.56"
4
+ VERSION = "8.1.57"
5
5
  end
@@ -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
- succes: success.
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
- succes: het ding is aangemaakt.
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_&quot;&gt;&lt;img src=x onerror=alert(1)&gt;_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 = "&lt;img src=x onerror=alert(1)&gt;"
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.56
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
-