inline_forms 8.1.55 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/app/controllers/concerns/file_slots_concern.rb +6 -0
  4. data/app/controllers/inline_forms_controller.rb +88 -30
  5. data/app/helpers/inline_forms_file_trash_helper.rb +10 -0
  6. data/app/models/concerns/inline_forms/stored_files.rb +5 -1
  7. data/app/models/inline_forms/trashed_file.rb +16 -2
  8. data/app/views/devise/sessions/_form.html.erb +2 -2
  9. data/app/views/inline_forms/_list.html.erb +24 -7
  10. data/app/views/inline_forms/_versions.html.erb +1 -1
  11. data/app/views/inline_forms/_versions_file_entry.html.erb +1 -1
  12. data/app/views/inline_forms/_versions_list.html.erb +14 -12
  13. data/app/views/layouts/application.html.erb +10 -3
  14. data/app/views/layouts/devise.html.erb +1 -1
  15. data/app/views/layouts/inline_forms.html.erb +9 -2
  16. data/lib/inline_forms/form_elements/file_field_helper.rb +3 -1
  17. data/lib/inline_forms/form_elements/info_list_helper.rb +3 -1
  18. data/lib/inline_forms/gem_files.rb +3 -2
  19. data/lib/inline_forms/version.rb +1 -1
  20. data/lib/locales/inline_forms.en.yml +22 -1
  21. data/lib/locales/inline_forms.nl.yml +25 -1
  22. data/test/dummy/app/controllers/application_controller.rb +5 -2
  23. data/test/dummy/app/controllers/daily_reports_controller.rb +4 -0
  24. data/test/dummy/app/models/daily_report.rb +21 -0
  25. data/test/dummy/app/models/machine.rb +1 -0
  26. data/test/dummy/config/application.rb +11 -0
  27. data/test/dummy/config/routes.rb +5 -0
  28. data/test/gem_package_modes_test.rb +1 -1
  29. data/test/integration/csp_nonce_test.rb +50 -0
  30. data/test/integration/file_slots_test.rb +91 -0
  31. data/test/integration/html_refusal_test.rb +152 -0
  32. data/test/integration/list_params_test.rb +200 -0
  33. data/test/integration/locale_test.rb +113 -0
  34. data/test/integration/presentation_escaping_test.rb +80 -0
  35. data/test/integration_test_helper.rb +11 -0
  36. metadata +15 -2
  37. 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: 7fff4c0c21d69de2e6836a1a6a16e1e293b8c740fe0615a429ab6775f89fd59a
4
- data.tar.gz: 479db5cb0c201625bc4c5561f1a3188d3e87ce128387c6a7e08c708d749600b2
3
+ metadata.gz: 93e0cd0c2bc09c666bd81cf76779b780ab345f4a7e80f8a6535177d7921b150d
4
+ data.tar.gz: 02e8abc26971fe105a65b8bae7380d171e2635e1e51385c0849917e1a5365c16
5
5
  SHA512:
6
- metadata.gz: 20499e3a0edf5819e20dc5a2d6893835acb72a08039df25190f184136c93d8c8904de2c5f6def7d3746dd7a1804eb01f51cc3560bd70d5d196e4a95be62911bf
7
- data.tar.gz: 809595fc167bab2e5c9015fb034dcc10be2ff6d5dde3b958d24cdf2ae96ed027ed56577342e5e56f4d5e3f6a02311cb7662a736dfe89636456f96961442bb5bb
6
+ metadata.gz: ecdf5c3b1c43851b0cd0d6b05fdac85a51b227c30584a52bb395c9786c1dffdb9378e549b6d99a1faf5ff11aa8bfc7ecda01ae1b92e494cf2dfdf9750fc3fae4
7
+ data.tar.gz: df8a5b2b0cd7a9f21a9b14e58a086ed7ace99f9ce6380bd1e8284cae6b65183b4db72e15de2c8ffa332e9366d85780dc6ccb125e12bfa7afa4a24ed204383a7a
data/CHANGELOG.md CHANGED
@@ -4,6 +4,98 @@ 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
+
73
+ ## [8.1.56] - 2026-10-05
74
+
75
+ ### Fixed
76
+
77
+ - **HTML requests the engine refuses get an explicit answer instead of falling through to implicit rendering.** Several `InlineFormsController` actions registered `format.html` only behind a guard, or registered it with a block that rendered nothing on the refused path. Rails then rendered implicitly: `ActionController::MissingExactTemplate` (a 500 for browser requests), `UnknownFormat` (406) or a bare 204. That meant error pages and exception-notifier noise. StProject hit it as a 500 on `GET /dagrapportages` by a normal user: `Dagrapportage` is `not_accessible_through_html?` and only exists nested in a Client tab. Now:
78
+ - `index` of a `not_accessible_through_html?` model without `parent_class` → **404**. The resource has no top-level page.
79
+ - `new`, `create`, row `show`, `destroy`, `soft_delete`, `soft_restore` and `revert` without the frame context the gem needs (`update`, plus `parent_class` or a nested row frame for `not_accessible_through_html?` models) → **400**. The request is malformed for this UI rather than aimed at a missing resource, so 400 rather than 404. A guard that does not match the request is a refusal of the request.
80
+ - `destroy` by a user who may not hard-destroy (`destroy_permitted?`, i.e. not superadmin) → **403**, the same answer `revert` already gave. It used to render nothing.
81
+ - `create`, `destroy`, `soft_delete`, `soft_restore` and `revert` now refuse **before writing**. Before, `create` saved the record, and `destroy`/`revert` changed data, and only then failed to respond.
82
+ - The refusals happen after `load_and_authorize_resource` / `authorize!`, so a host's CanCan `check_authorization` stays satisfied. The working paths are unchanged, including the turbo_stream responses (open-after-create, revert), nested lists, forms and rows, and accessible models' lists and rows.
83
+ - **File-slot actions under `check_authorization`.** `file_slot_request_permitted?` answered 400 for an unlisted attribute before any `authorize!`, so a host with CanCan's `check_authorization` raised `CanCan::AuthorizationNotPerformed` (500) instead. It now authorizes the record first, then the attribute.
84
+
85
+ ### Tests
86
+
87
+ - `test/integration/html_refusal_test.rb`, using new dummy model `DailyReport` (a `not_accessible_through_html?` child of `Machine`):
88
+ - top-level index → 404; show, new and revert without a nested frame → 400; create → 400 with nothing created; destroy → 400 with the record kept;
89
+ - nested list, new, create, row, destroy and revert still work;
90
+ - an accessible model's list and row still render, and its new/create without `update` → 400;
91
+ - a non-superadmin destroy → 403;
92
+ - a refused file-slot request still counts as authorized.
93
+ - Every refusal also asserts that authorization ran.
94
+
95
+ ### Lockstep
96
+
97
+ - validation_hints 8.1.56, inline_forms_installer 8.1.56, inline_forms_schema_edit 8.1.56.
98
+
7
99
  ## [8.1.55] - 2026-10-04
8
100
 
9
101
  ### Fixed
@@ -136,10 +136,16 @@ module FileSlotsConcern
136
136
  # Validates the attribute (declared slot shown as :simple_file_field in the
137
137
  # attribute list), then authorizes +action+ on it. Renders and returns false
138
138
  # when refused.
139
+ #
140
+ # The record is authorized first (these actions skip
141
+ # load_and_authorize_resource): a 400 for an unlisted attribute must still
142
+ # count as an authorization check, or a host with CanCan's
143
+ # +check_authorization+ raises AuthorizationNotPerformed (500) instead.
139
144
  def file_slot_request_permitted?(action)
140
145
  @attribute = params[:attribute].to_s
141
146
  @form_element = "simple_file_field"
142
147
  @update_span = params[:update]
148
+ authorize!(action, @object) if cancan_enabled?
143
149
  unless inline_forms_file_slot_listed?(@object, @attribute)
144
150
  head :bad_request
145
151
  return false
@@ -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
@@ -84,6 +87,11 @@ class InlineFormsController < ApplicationController
84
87
  format.html do
85
88
  if @parent_class.present?
86
89
  render_nested_associated_list_html
90
+ else
91
+ # No top-level page for this resource. An explicit answer: an
92
+ # empty block would fall through to implicit rendering and raise
93
+ # MissingExactTemplate (500).
94
+ head :not_found
87
95
  end
88
96
  end
89
97
  else
@@ -116,7 +124,7 @@ class InlineFormsController < ApplicationController
116
124
 
117
125
  @object.inline_forms_attribute_list = @inline_forms_attribute_list if @inline_forms_attribute_list
118
126
  respond_to do |format|
119
- format.html { render_turbo_new } if html_list_flow_allowed?
127
+ format.html { html_list_flow_allowed? ? render_turbo_new : head(:bad_request) }
120
128
  end
121
129
  end
122
130
 
@@ -139,6 +147,11 @@ class InlineFormsController < ApplicationController
139
147
  def create
140
148
  @object ||= @Klass.new
141
149
  @update_span = params[:update]
150
+ @parent_class = params[:parent_class]
151
+ # Refuse before anything is written: the responses below only exist for
152
+ # a request from a list or nested-list frame.
153
+ return head :bad_request unless html_list_flow_allowed?
154
+
142
155
  attributes = @inline_forms_attribute_list || @object.inline_forms_attribute_list
143
156
  attributes.each do | attribute, form_element |
144
157
  # Skip a row whose column is not migrated yet (model edited, migration
@@ -149,7 +162,6 @@ class InlineFormsController < ApplicationController
149
162
  InlineForms.assert_plain_text_column!(object: @object, attribute: attribute, form_element: form_element)
150
163
  send("#{form_element}_update", @object, attribute) unless form_element == :associated || (cancan_enabled? && cannot?(:read, @object, attribute))
151
164
  end
152
- @parent_class = params[:parent_class]
153
165
  @parent_id = params[:parent_id]
154
166
  # See #index for the order/search/parent-fk decomposition.
155
167
  fk_conditions = nil
@@ -171,19 +183,17 @@ class InlineFormsController < ApplicationController
171
183
  created_object = @object
172
184
  @object = nil
173
185
  respond_to do |format|
174
- if html_list_flow_allowed?
175
- if open_created_row?(created_object, attributes)
176
- format.turbo_stream { render_created_row_open_streams(created_object) }
177
- end
178
- format.html { render_list_frame_after_save }
186
+ if open_created_row?(created_object, attributes)
187
+ format.turbo_stream { render_created_row_open_streams(created_object) }
179
188
  end
189
+ format.html { render_list_frame_after_save }
180
190
  end
181
191
  else
182
192
  flash.now[:header] = [ "Kan #{@object.class.to_s.underscore} niet aanmaken." ]
183
193
  flash.now[:error] = @object.errors.to_a
184
194
  respond_to do |format|
185
195
  @object.inline_forms_attribute_list = attributes
186
- format.html { render_turbo_new } if html_list_flow_allowed?
196
+ format.html { render_turbo_new }
187
197
  end
188
198
  end
189
199
  end
@@ -257,10 +267,10 @@ class InlineFormsController < ApplicationController
257
267
  if @attribute.nil?
258
268
  respond_to do |format|
259
269
  @attributes = @object.inline_forms_attribute_list
260
- if close
261
- format.html { render_row_turbo(:close) } if row_html_turbo_allowed?
262
- else
263
- format.html { render_row_turbo(:show) } if row_html_turbo_allowed?
270
+ format.html do
271
+ next head(:bad_request) unless row_html_turbo_allowed?
272
+
273
+ render_row_turbo(close ? :close : :show)
264
274
  end
265
275
  end
266
276
  else
@@ -274,9 +284,11 @@ class InlineFormsController < ApplicationController
274
284
  def soft_delete
275
285
  @update_span = params[:update]
276
286
  @object = referenced_object
287
+ return head :bad_request unless row_html_turbo_allowed?
288
+
277
289
  @object.soft_delete(current_user)
278
290
  respond_to do |format|
279
- format.html { render_row_turbo(:close) } if row_html_turbo_allowed?
291
+ format.html { render_row_turbo(:close) }
280
292
  end
281
293
  end
282
294
 
@@ -284,9 +296,11 @@ class InlineFormsController < ApplicationController
284
296
  def soft_restore
285
297
  @update_span = params[:update]
286
298
  @object = referenced_object
299
+ return head :bad_request unless row_html_turbo_allowed?
300
+
287
301
  @object.soft_restore
288
302
  respond_to do |format|
289
- format.html { render_row_turbo(:close) } if row_html_turbo_allowed?
303
+ format.html { render_row_turbo(:close) }
290
304
  end
291
305
  end
292
306
 
@@ -294,14 +308,17 @@ class InlineFormsController < ApplicationController
294
308
  def destroy
295
309
  @update_span = params[:update]
296
310
  @object = referenced_object
297
- if destroy_permitted?
298
- @object.destroy
299
- # Capture after destroy: `.last` before destroy was the latest *update*
300
- # (e.g. a plain_text_area edit), so undo reified the pre-edit state.
301
- @undo_version = @object.versions.last
302
- respond_to do |format|
303
- format.html { render_row_turbo_destroyed } if row_html_turbo_allowed?
304
- end
311
+ # Both refusals answer before anything is destroyed (they used to render
312
+ # nothing and fall through to implicit rendering: a 500).
313
+ return head :forbidden unless destroy_permitted?
314
+ return head :bad_request unless row_html_turbo_allowed?
315
+
316
+ @object.destroy
317
+ # Capture after destroy: `.last` before destroy was the latest *update*
318
+ # (e.g. a plain_text_area edit), so undo reified the pre-edit state.
319
+ @undo_version = @object.versions.last
320
+ respond_to do |format|
321
+ format.html { render_row_turbo_destroyed }
305
322
  end
306
323
  end
307
324
 
@@ -327,6 +344,7 @@ class InlineFormsController < ApplicationController
327
344
  # has the Devise+roles system, otherwise no extra gate beyond CanCan.
328
345
  return head :forbidden unless destroy_permitted?
329
346
  return head :not_found unless @parent
347
+ return head :bad_request unless row_html_turbo_allowed?
330
348
 
331
349
  @object = reify_for_revert(@version)
332
350
  # PaperTrail::Version#reify returns nil for `create` events because
@@ -354,8 +372,7 @@ class InlineFormsController < ApplicationController
354
372
  @parent = item || @parent
355
373
  return head :not_found unless @parent
356
374
  end
357
- return render_revert_response if row_html_turbo_allowed?
358
- return
375
+ return render_revert_response
359
376
  end
360
377
  if defined?(ActionText::RichText) && @object.is_a?(ActionText::RichText)
361
378
  @rich_text_record = @object
@@ -366,19 +383,60 @@ class InlineFormsController < ApplicationController
366
383
  else
367
384
  # One transaction: the record, its files coming back out of the trash
368
385
  # (undo of a destroy) and its rich texts are restored together or not.
369
- @object.class.transaction do
370
- file_entries = InlineForms::StoredFiles.prepare_revert!(@object)
371
- @parent = persist_reverted_primary!(@object, @version)
372
- InlineForms::StoredFiles.finish_revert!(file_entries, by: inline_forms_current_user_id)
373
- 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
374
398
  end
375
399
  @parent.reload
376
400
  end
377
- render_revert_response if row_html_turbo_allowed?
401
+ render_revert_response
378
402
  end
379
403
 
380
404
  private
381
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
+
382
440
  # Single-field requests (edit, update, show with an attribute) name the
383
441
  # field and its form element in params, and both are client-controlled.
384
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" />