camaleon_cms 2.9.1 → 2.9.3

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 (241) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +29 -9
  3. data/Rakefile +3 -16
  4. data/app/apps/plugins/attack/admin_controller.rb +9 -6
  5. data/app/apps/plugins/attack/attack_helper.rb +35 -25
  6. data/app/apps/plugins/authoring_post/authoring_post_helper.rb +23 -16
  7. data/app/apps/plugins/front_cache/admin_controller.rb +16 -10
  8. data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
  9. data/app/apps/plugins/front_cache/front_cache_helper.rb +57 -24
  10. data/app/apps/plugins/visibility_post/config/config.json +1 -0
  11. data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
  12. data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
  13. data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
  14. data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
  15. data/app/apps/themes/default/assets/css/style.css.scss +24 -0
  16. data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
  17. data/app/apps/themes/default/default_helper.rb +2 -1
  18. data/app/apps/themes/default/views/category.html.erb +1 -1
  19. data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
  20. data/app/apps/themes/default/views/post_tag.html.erb +1 -1
  21. data/app/apps/themes/default/views/post_type.html.erb +1 -1
  22. data/app/apps/themes/default/views/search.html.erb +1 -1
  23. data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
  24. data/app/apps/themes/new/custom_helper.rb +5 -9
  25. data/app/apps/themes/new/views/category.html.erb +1 -1
  26. data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
  27. data/app/apps/themes/new/views/post_tag.html.erb +1 -1
  28. data/app/apps/themes/new/views/post_type.html.erb +1 -1
  29. data/app/apps/themes/new/views/search.html.erb +1 -1
  30. data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
  31. data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
  32. data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
  33. data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
  34. data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +29 -10
  35. data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
  36. data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +9 -2
  37. data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +4 -3
  38. data/app/controllers/camaleon_cms/admin/appearances/widgets/sidebar_controller.rb +2 -2
  39. data/app/controllers/camaleon_cms/admin/categories_controller.rb +27 -8
  40. data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
  41. data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
  42. data/app/controllers/camaleon_cms/admin/media_controller.rb +84 -19
  43. data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
  44. data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +15 -6
  45. data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
  46. data/app/controllers/camaleon_cms/admin/posts_controller.rb +58 -22
  47. data/app/controllers/camaleon_cms/admin/sessions_controller.rb +238 -45
  48. data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +103 -18
  49. data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +17 -5
  50. data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +33 -7
  51. data/app/controllers/camaleon_cms/admin/settings_controller.rb +11 -6
  52. data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +9 -7
  53. data/app/controllers/camaleon_cms/admin/users_controller.rb +89 -18
  54. data/app/controllers/camaleon_cms/admin_controller.rb +144 -26
  55. data/app/controllers/camaleon_cms/apps/plugins_admin_controller.rb +5 -0
  56. data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
  57. data/app/controllers/camaleon_cms/frontend_controller.rb +140 -70
  58. data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +44 -0
  59. data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
  60. data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
  61. data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
  62. data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
  63. data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
  64. data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
  65. data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
  66. data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
  67. data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
  68. data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
  69. data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
  70. data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
  71. data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
  72. data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
  73. data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
  74. data/app/decorators/camaleon_cms/post_decorator.rb +67 -51
  75. data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
  76. data/app/decorators/camaleon_cms/site_decorator.rb +29 -23
  77. data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
  78. data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
  79. data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
  80. data/app/helpers/camaleon_cms/admin/application_helper.rb +21 -24
  81. data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
  82. data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
  83. data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
  84. data/app/helpers/camaleon_cms/admin/post_type_helper.rb +51 -31
  85. data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
  86. data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
  87. data/app/helpers/camaleon_cms/comment_helper.rb +87 -41
  88. data/app/helpers/camaleon_cms/content_helper.rb +13 -6
  89. data/app/helpers/camaleon_cms/email_helper.rb +7 -3
  90. data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
  91. data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +144 -67
  92. data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
  93. data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
  94. data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
  95. data/app/helpers/camaleon_cms/html_helper.rb +73 -38
  96. data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
  97. data/app/helpers/camaleon_cms/session_helper.rb +195 -39
  98. data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
  99. data/app/helpers/camaleon_cms/site_helper.rb +43 -9
  100. data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
  101. data/app/helpers/camaleon_cms/uploader_helper.rb +19 -422
  102. data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
  103. data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
  104. data/app/models/camaleon_cms/ability.rb +65 -105
  105. data/app/models/camaleon_cms/category.rb +7 -5
  106. data/app/models/camaleon_cms/custom_field.rb +29 -6
  107. data/app/models/camaleon_cms/custom_field_group.rb +75 -21
  108. data/app/models/camaleon_cms/custom_fields_relationship.rb +133 -2
  109. data/app/models/camaleon_cms/media.rb +8 -2
  110. data/app/models/camaleon_cms/meta.rb +2 -2
  111. data/app/models/camaleon_cms/nav_menu.rb +6 -2
  112. data/app/models/camaleon_cms/nav_menu_item.rb +18 -8
  113. data/app/models/camaleon_cms/plugin.rb +4 -3
  114. data/app/models/camaleon_cms/post.rb +113 -27
  115. data/app/models/camaleon_cms/post_comment.rb +10 -6
  116. data/app/models/camaleon_cms/post_default.rb +42 -12
  117. data/app/models/camaleon_cms/post_relationship.rb +6 -4
  118. data/app/models/camaleon_cms/post_tag.rb +4 -3
  119. data/app/models/camaleon_cms/post_type.rb +22 -12
  120. data/app/models/camaleon_cms/site.rb +40 -28
  121. data/app/models/camaleon_cms/term_relationship.rb +10 -7
  122. data/app/models/camaleon_cms/term_taxonomy.rb +51 -5
  123. data/app/models/camaleon_cms/theme.rb +3 -3
  124. data/app/models/camaleon_cms/user.rb +17 -5
  125. data/app/models/camaleon_cms/user_role.rb +77 -4
  126. data/app/models/camaleon_cms/widget/assigned.rb +11 -6
  127. data/app/models/camaleon_cms/widget/main.rb +13 -7
  128. data/app/models/camaleon_cms/widget/sidebar.rb +3 -6
  129. data/app/models/camaleon_record.rb +123 -5
  130. data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +20 -38
  131. data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
  132. data/app/models/concerns/camaleon_cms/custom_fields_read.rb +115 -58
  133. data/app/models/concerns/camaleon_cms/metas.rb +32 -7
  134. data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
  135. data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
  136. data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
  137. data/app/models/current_request.rb +23 -0
  138. data/app/uploaders/camaleon_cms_aws_uploader.rb +39 -11
  139. data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
  140. data/app/uploaders/camaleon_cms_uploader.rb +23 -10
  141. data/app/validators/camaleon_cms/post_uniq_validator.rb +21 -8
  142. data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
  143. data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
  144. data/app/views/camaleon_cms/admin/appearances/nav_menus/_left_menu_items.html.erb +2 -2
  145. data/app/views/camaleon_cms/admin/appearances/widgets/main/form.html.erb +1 -1
  146. data/app/views/camaleon_cms/admin/categories/index.html.erb +3 -3
  147. data/app/views/camaleon_cms/admin/comments/index.html.erb +2 -2
  148. data/app/views/camaleon_cms/admin/comments/list.html.erb +1 -1
  149. data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
  150. data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
  151. data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
  152. data/app/views/camaleon_cms/admin/post_tags/index.html.erb +3 -3
  153. data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +2 -2
  154. data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
  155. data/app/views/camaleon_cms/admin/posts/index.html.erb +6 -6
  156. data/app/views/camaleon_cms/admin/search.html.erb +1 -1
  157. data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
  158. data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
  159. data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
  160. data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
  161. data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +42 -6
  162. data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
  163. data/app/views/camaleon_cms/admin/settings/custom_fields/fields/_select_eval.html.erb +1 -1
  164. data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +5 -4
  165. data/app/views/camaleon_cms/admin/settings/custom_fields/index.html.erb +1 -1
  166. data/app/views/camaleon_cms/admin/settings/post_types/index.html.erb +1 -1
  167. data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
  168. data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
  169. data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
  170. data/app/views/camaleon_cms/admin/settings/sites/index.html.erb +1 -1
  171. data/app/views/camaleon_cms/admin/user_roles/form.html.erb +102 -7
  172. data/app/views/camaleon_cms/admin/user_roles/index.html.erb +1 -1
  173. data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
  174. data/app/views/camaleon_cms/admin/users/index.html.erb +2 -2
  175. data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
  176. data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
  177. data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
  178. data/app/views/layouts/camaleon_cms/admin/_flash_messages.html.erb +2 -2
  179. data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
  180. data/config/initializers/action_view.rb +42 -5
  181. data/config/initializers/active_record_extension.rb +13 -27
  182. data/config/initializers/assets.rb +52 -23
  183. data/config/initializers/custom_initializers.rb +3 -3
  184. data/config/locales/camaleon_cms/admin/ar.yml +7 -2
  185. data/config/locales/camaleon_cms/admin/de.yml +7 -2
  186. data/config/locales/camaleon_cms/admin/en.yml +39 -2
  187. data/config/locales/camaleon_cms/admin/es.yml +7 -2
  188. data/config/locales/camaleon_cms/admin/fr.yml +7 -2
  189. data/config/locales/camaleon_cms/admin/it.yml +7 -2
  190. data/config/locales/camaleon_cms/admin/nl.yml +8 -2
  191. data/config/locales/camaleon_cms/admin/pt-BR.yml +7 -2
  192. data/config/locales/camaleon_cms/admin/pt.yml +7 -2
  193. data/config/locales/camaleon_cms/admin/ru.yml +7 -2
  194. data/config/locales/camaleon_cms/admin/uk.yml +7 -2
  195. data/config/locales/camaleon_cms/admin/zh-CH.yml +7 -2
  196. data/config/routes/admin.rb +33 -15
  197. data/config/routes/frontend.rb +21 -15
  198. data/db/migrate/20150611161134_post_table_into_utf8.rb +14 -14
  199. data/db/migrate/20150926095310_rename_column_posts.rb +3 -3
  200. data/db/migrate/20151212095328_add_confirm_token_to_users.rb +3 -3
  201. data/db/migrate/20160504155652_add_feature_to_posts.rb +1 -1
  202. data/db/migrate/20160504155653_move_first_name_of_users.rb +2 -2
  203. data/db/migrate/20160609121449_add_group_to_custom_field_values.rb +1 -1
  204. data/db/migrate/20161215202255_drop_user_relationship_table.rb +1 -1
  205. data/db/migrate/20180124132318_create_media.rb +1 -1
  206. data/db/migrate/20180704211100_adjust_field_length.rb +1 -1
  207. data/lib/camaleon_cms/assets_precompile.rb +86 -0
  208. data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
  209. data/lib/camaleon_cms/content_security.rb +158 -0
  210. data/lib/camaleon_cms/engine.rb +47 -9
  211. data/lib/camaleon_cms/media_security_headers.rb +27 -0
  212. data/lib/camaleon_cms/setup_token.rb +69 -0
  213. data/lib/camaleon_cms/svg_content_checker.rb +123 -0
  214. data/lib/camaleon_cms/task_reporter.rb +14 -0
  215. data/lib/camaleon_cms/unsafe_markup.rb +207 -0
  216. data/lib/camaleon_cms/uploader_content_security.rb +248 -0
  217. data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
  218. data/lib/camaleon_cms/uploader_path_security.rb +146 -0
  219. data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
  220. data/lib/camaleon_cms/uploader_support.rb +86 -0
  221. data/lib/camaleon_cms/version.rb +1 -1
  222. data/lib/camaleon_cms.rb +11 -0
  223. data/lib/ext/hash.rb +28 -2
  224. data/lib/ext/string.rb +13 -8
  225. data/lib/ext/translator.rb +10 -2
  226. data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
  227. data/lib/generators/camaleon_cms/install_generator.rb +7 -4
  228. data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
  229. data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
  230. data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
  231. data/lib/plugin_routes.rb +412 -368
  232. data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
  233. data/lib/tasks/cross_site_field_groups.rake +74 -0
  234. data/lib/tasks/custom_fields_roles.rake +58 -0
  235. data/lib/tasks/orphaned_comments.rake +42 -0
  236. data/lib/tasks/private_upload_acls.rake +31 -0
  237. data/lib/tasks/site_custom_field_groups.rake +36 -0
  238. data/lib/tasks/unsafe_stored_content.rake +42 -0
  239. data/lib/tasks/unsafe_stored_uploads.rake +70 -0
  240. data/lib/tasks/user_field_groups.rake +27 -0
  241. metadata +185 -7
@@ -6,10 +6,22 @@ module CamaleonCms
6
6
  # redirect_url (default nil): after initialized the session, this will be redirected to
7
7
  # "redirect_url" if defined
8
8
  # it doesn't redirect if redirect_url === false
9
- # return to previous page if defined the cookie['return_to'] or login url received extra param: return_to=https://mysite.com
10
- def login_user(user, remember_me = false, redirect_url = nil)
11
- c = { value: [user.auth_token, request.user_agent, request.ip], expires: 24.hours.from_now }
12
- c[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
9
+ # return to previous page if defined the cookie['return_to'], or login
10
+ # url received extra param: return_to=https://mysite.com
11
+ # allow_external (default false): follow redirect_url even when it points off-site, for a caller that
12
+ # has vetted the destination (e.g. an after_login hook doing SSO/payment). http(s) only; a configured
13
+ # allowlist (redirect_allowed_hosts option / safe_redirect_hosts hook) is honored regardless of it.
14
+ def login_user(user, remember_me = false, redirect_url = nil, rotate_session: true, allow_external: false)
15
+ # Rotate the session on a genuine sign-in (H6): a fresh session drops any state left behind by a
16
+ # previous occupant of a shared browser — notably an impersonation parent_auth_token, which
17
+ # session_back_to_parent would otherwise restore on logout, escalating the new user to admin.
18
+ # Impersonation manages that token itself and passes rotate_session: false so this does not wipe it.
19
+ reset_session if rotate_session
20
+
21
+ # Security (audit 2026-08-11 M3): the auth cookie is HttpOnly (JavaScript/XSS cannot read the
22
+ # bearer token) and Secure over an SSL request (never sent in the clear). The three components are
23
+ # joined into one string so the split-based reader and the impersonation stash keep their shape.
24
+ c = cama_auth_cookie_options([user.auth_token, request.user_agent, request.ip].join('&'))
13
25
  c[:expires] = 1.month.from_now if remember_me
14
26
 
15
27
  # fix to overwrite a cookie
@@ -24,9 +36,13 @@ module CamaleonCms
24
36
  return if redirect_url == false
25
37
 
26
38
  if redirect_url.present?
27
- redirect_to redirect_url
39
+ # Host-check the explicit redirect_url too: after_login hooks and downstream plugins pass
40
+ # caller-controlled destinations here (e.g. a return_to cookie), so it gets the same open-redirect
41
+ # guard as the return_to cookie branch below. allow_external lets such a hook opt a vetted off-site
42
+ # destination (SSO/payment) past the same-host rule; it stays http(s)-only either way.
43
+ cama_safe_redirect(redirect_url, cama_admin_dashboard_path, allow_external: allow_external)
28
44
  elsif (return_to = cookies.delete(:return_to)).present?
29
- redirect_to return_to
45
+ cama_safe_redirect(return_to, cama_admin_dashboard_path)
30
46
  else
31
47
  redirect_to cama_admin_dashboard_path
32
48
  end
@@ -35,13 +51,14 @@ module CamaleonCms
35
51
  # login a user using username and password
36
52
  # return boolean: true => authenticated, false => authentication failed
37
53
  def login_user_with_password(username, password)
38
- @user = current_site.users.find_by_username(username)
39
- r = { user: @user, params: params, password: password, captcha_validate: true }
54
+ # Custom class finder: usernames are stored downcased, so the lookup must
55
+ # lower-case both sides regardless of DB collation.
56
+ user = current_site.users.find_by_username(username) # rubocop:disable Rails/DynamicFindBy
57
+ r = { user: user, params: params, password: password, captcha_validate: true }
40
58
  hooks_run('user_before_login', r)
41
- @user&.authenticate(password)
59
+ user&.authenticate(password)
42
60
  end
43
61
 
44
- ##
45
62
  # User registration.
46
63
  #
47
64
  # user_data must contain:
@@ -50,22 +67,42 @@ module CamaleonCms
50
67
  # - username
51
68
  # - password
52
69
  # - password_confirmation
53
-
54
70
  def cama_register_user(user_data, meta)
55
- @user = current_site.users.new(user_data)
56
- r = { user: @user, params: params }
57
- hook_run('user_before_register', r)
71
+ user = current_site.users.new(user_data)
58
72
 
73
+ # Gate on the register captcha before running any hook, so user_before_register does not fire for
74
+ # attempts that fail the captcha. This diverges from user_before_login, which runs its hook even on a
75
+ # failed captcha and passes the result as r[:captcha_validate]; user_before_register does neither, so
76
+ # the "parity with user_before_login" below is limited to the stop_process veto. Verifying here also
77
+ # consumes the single-use captcha before the hook, so a later veto burns a solved challenge —
78
+ # harmless, since the re-rendered form issues a fresh one.
59
79
  if current_site.security_user_register_captcha_enabled? && !cama_captcha_verified?
60
- { result: false, type: :captcha_error, message: t('camaleon_cms.admin.users.message.error_captcha') }
61
- elsif @user.save
62
- @user.set_metas(meta)
63
- message = current_site.need_validate_email? ? t('camaleon_cms.admin.users.message.created_pending_validate_email') : t('camaleon_cms.admin.users.message.created')
64
- r = { user: @user, message: message, redirect_url: cama_admin_login_path }
80
+ return { result: false, type: :captcha_error,
81
+ message: t('camaleon_cms.admin.users.message.error_captcha'), user: user }
82
+ end
83
+
84
+ r = { user: user, params: params, stop_process: false }
85
+ # Broadcast (hooks_run), not hook_run: hook_run(target, name, …) takes the app as its first arg, so
86
+ # hook_run('user_before_register', r) treated the name as a plugin and silently no-op'd — the hook
87
+ # never fired. Match the sibling user_before_login/user_after_register broadcasts.
88
+ hooks_run('user_before_register', r)
89
+ # A handler may veto the registration by setting r[:stop_process] (parity with user_before_login):
90
+ # stop here without saving. The handler may surface its own reason (add to r[:user].errors, or
91
+ # render/redirect); otherwise the controller shows a generic error.
92
+ return { result: false, type: :stopped, user: user } if r[:stop_process]
93
+
94
+ if user.save
95
+ user.set_metas(meta)
96
+ message = if current_site.need_validate_email?
97
+ t('camaleon_cms.admin.users.message.created_pending_validate_email')
98
+ else
99
+ t('camaleon_cms.admin.users.message.created')
100
+ end
101
+ r = { user: user, message: message, redirect_url: cama_admin_login_path }
65
102
  hooks_run('user_after_register', r)
66
- { result: true, message: r[:message], redirect_url: r[:redirect_url] }
103
+ { result: true, message: r[:message], redirect_url: r[:redirect_url], user: user }
67
104
  else
68
- { result: false, type: :no_saved }
105
+ { result: false, type: :no_saved, user: user }
69
106
  end
70
107
  end
71
108
 
@@ -79,55 +116,87 @@ module CamaleonCms
79
116
  def session_switch_user(user, redirect_url = nil)
80
117
  return unless cama_sign_in?
81
118
 
82
- session[:parent_auth_token] = cookies[:auth_token]
83
- login_user(user, false, redirect_url)
119
+ # Start impersonation from a clean session (see login_user), then stash the admin's auth cookie.
120
+ # The stash happens AFTER the reset and login_user is told not to rotate again, so the token
121
+ # session_back_to_parent restores later survives this rotation (H6).
122
+ parent_auth_token = cookies[:auth_token]
123
+ reset_session
124
+ session[:parent_auth_token] = parent_auth_token
125
+ login_user(user, false, redirect_url, rotate_session: false)
126
+ end
127
+
128
+ # The admin who started the current impersonation, resolved from the stashed parent auth token
129
+ # (same lookup as cama_current_user). Returns nil if there is no active impersonation or the stash
130
+ # no longer resolves to a user (e.g. the admin rotated their token by changing their password).
131
+ # Callers MUST re-authenticate this user before session_back_to_parent (H6 residual): the
132
+ # impersonated session an admin holds is indistinguishable from one they abandon, so the admin's
133
+ # password is the only proof that the person returning is the admin and not a later occupant.
134
+ def cama_impersonation_parent_user
135
+ token = session[:parent_auth_token].to_s.split('&').first
136
+ return if token.blank?
137
+
138
+ current_site.users_include_admins.find_by(auth_token: token)
84
139
  end
85
140
 
86
141
  # switch current session into parent session called by session_switch_user
87
142
  # after returned into parent session, this will be redirected to redirect_url or admin dashboard
143
+ # SECURITY: only call this once the parent admin has been re-authenticated (see
144
+ # Admin::SessionsController#back_to_parent and cama_impersonation_parent_user).
88
145
  def session_back_to_parent(redirect_url = nil)
89
146
  return unless cama_sign_in? && session[:parent_auth_token].present?
90
147
 
91
- cookies[:auth_token] = session[:parent_auth_token]
148
+ cookies[:auth_token] = cama_auth_cookie_options(session[:parent_auth_token])
92
149
  session.delete(:parent_auth_token)
93
150
  redirect_to (redirect_url || cama_admin_dashboard_path), notice: 'Welcome back!'
94
151
  end
95
152
 
96
153
  # logout current user
97
154
  def cama_logout_user
155
+ # Security (audit 2026-08-11 M3): rotate the server-side token so a cookie copied before logout
156
+ # cannot be replayed afterwards. The token is per-user, so this also ends the user's other sessions.
157
+ # Audit M14: skip the rotation during impersonation -- cama_current_user is then the impersonated
158
+ # user, not the admin ending the session, and rotating their token would log the innocent user
159
+ # out of all their own devices. session_back_to_parent is the intended exit from impersonation.
160
+ cama_current_user&.cama_reset_auth_token! if session[:parent_auth_token].blank?
98
161
  cookies.delete(:auth_token, domain: :all)
99
162
  cookies.delete(:auth_token, domain: nil)
100
163
  c_data = { value: nil, expires: 24.hours.ago }
101
164
  c_data[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
102
165
  cookies[:auth_token] = c_data
103
- redirect_to params[:return_to].present? ? params[:return_to] : cama_admin_login_path,
104
- notice: t('camaleon_cms.admin.logout.message.closed')
166
+ CurrentRequest.user = nil
167
+ CurrentRequest.user_resolved = true # the cookie is gone; don't re-resolve to a stale user
168
+ # Drop all server-side session state on logout (H6): a lingering impersonation parent_auth_token
169
+ # must not survive to be restored into a later session on a shared browser.
170
+ reset_session
171
+ cama_safe_redirect(params[:return_to], cama_admin_login_path,
172
+ notice: t('camaleon_cms.admin.logout.message.closed'))
105
173
  end
106
174
 
107
- # check if current user is already signed
175
+ # Check if the current user is already signed
108
176
  def cama_sign_in?
109
177
  !cama_current_user.nil?
110
178
  end
111
179
 
112
180
  alias signin? cama_sign_in?
113
181
 
114
- # return the role for current user
182
+ # return the role for the current user
115
183
  # if not logged in, then return 'public'
116
184
  def cama_current_role
117
185
  current_site.visitor_role
118
186
  end
119
187
 
120
- # return current user logged in
188
+ # return the current user logged in
121
189
  def cama_current_user
122
- return @cama_current_user if defined?(@cama_current_user)
123
-
124
- # api current user...
125
- @cama_current_user = cama_calc_api_current_user
126
- return @cama_current_user if @cama_current_user
190
+ # Honor an externally-set user, and memoize a nil resolution via user_resolved so a signed-out
191
+ # request (or a present-but-stale auth cookie) is not re-resolved on every call (regression M16).
192
+ return CurrentRequest.user if CurrentRequest.user || CurrentRequest.user_resolved
127
193
 
128
- return nil unless cookie_auth_token_complete?
129
-
130
- @cama_current_user = current_site.users_include_admins.find_by_auth_token(user_auth_token_from_cookie).try(:decorate)
194
+ CurrentRequest.user_resolved = true
195
+ user = cama_calc_api_current_user # api current user...
196
+ if user.nil? && cookie_auth_token_complete?
197
+ user = current_site.users_include_admins.find_by(auth_token: user_auth_token_from_cookie).try(:decorate)
198
+ end
199
+ CurrentRequest.user = user
131
200
  end
132
201
 
133
202
  def cookie_auth_token_complete?
@@ -159,7 +228,7 @@ module CamaleonCms
159
228
 
160
229
  # return the session id
161
230
  def cama_get_session_id
162
- session[:autor] = 'Owen Peredo Diaz' unless request.session_options[:id].present?
231
+ session[:autor] = 'Owen Peredo Diaz' if request.session_options[:id].blank?
163
232
  id = request.session_options[:id]
164
233
  id = id.public_id if id.instance_of?(::Rack::Session::SessionId)
165
234
  id
@@ -167,6 +236,93 @@ module CamaleonCms
167
236
 
168
237
  private
169
238
 
239
+ # Options for the auth cookie (M3): HttpOnly so JavaScript cannot read the bearer token, Secure over
240
+ # an SSL request so it is never sent in the clear, a 24h default lifetime, and the shared-domain
241
+ # scope when sites share users. Callers override :expires (remember-me) as needed.
242
+ def cama_auth_cookie_options(value)
243
+ opts = { value: value, httponly: true, secure: request.ssl?, expires: 24.hours.from_now }
244
+ opts[:domain] = :all if PluginRoutes.system_info['users_share_sites'].present? && CamaleonCms::Site.count > 1
245
+ opts
246
+ end
247
+
248
+ # validate redirect url to prevent open redirect attacks: relative, or same-host over http(s), only —
249
+ # unless the off-site host is explicitly trusted (see cama_redirect_allowed_hosts) or the caller vouches
250
+ # for it (allow_external, for hook-set SSO/payment destinations). Host compared case-insensitively
251
+ # (RFC 3986); a same-host absolute URL is emitted with the host in the request's canonical case, so
252
+ # Rails' own case-sensitive open-redirect protection stays active without tripping on it.
253
+ def safe_redirect_url(url, allow_external: false)
254
+ return if url.blank?
255
+
256
+ uri = URI.parse(url)
257
+ if uri.host.present?
258
+ # A non-HTTP scheme carrying a host (javascript://host/..., data://host/...) parses with a host but
259
+ # is not a real navigation — it would execute if rendered into an href/JS sink. Reject it before any
260
+ # host decision, so neither the allowlist nor allow_external can let such a scheme through.
261
+ return unless uri.scheme.blank? || %w[http https].include?(uri.scheme.downcase)
262
+
263
+ if uri.host.casecmp?(request.host)
264
+ uri.host = request.host
265
+ return uri.to_s
266
+ end
267
+
268
+ # Off-host: followed only for an admin/plugin-vetted host, or when the caller explicitly vouches for
269
+ # this destination (allow_external — a hook doing SSO/payment). Never for a caller-controlled
270
+ # return_to, which always passes allow_external: false.
271
+ return uri.to_s if allow_external || cama_redirect_host_allowed?(uri.host)
272
+
273
+ return
274
+ end
275
+
276
+ # A blank parsed host does not mean same-origin: a scheme (https:evil.com, javascript:...) or a
277
+ # slash/backslash-led form (///evil.com, /\evil.com, and their %2f/%5c encodings) still sends a
278
+ # browser off-site. Follow only a genuine same-origin path — one leading "/" then a normal path
279
+ # character (audit: the ///evil.com open redirect).
280
+ return unless uri.scheme.blank? && url.start_with?('/') && !url.match?(%r{\A/(?:[/\\]|%2f|%5c)}i)
281
+
282
+ url
283
+ rescue URI::InvalidURIError
284
+ nil
285
+ end
286
+
287
+ # Redirect to a destination vetted by safe_redirect_url, or to `fallback` when it is dropped. A vetted
288
+ # off-host destination (allowlisted or vouched via allow_external) is emitted with allow_other_host so
289
+ # Rails' backstop does not reject it; a same-host destination keeps that backstop as a second layer.
290
+ def cama_safe_redirect(url, fallback, allow_external: false, **opts)
291
+ target = safe_redirect_url(url, allow_external: allow_external)
292
+ return redirect_to(fallback, **opts) if target.blank?
293
+
294
+ opts[:allow_other_host] = true if cama_redirect_off_host?(target) && cama_supports_allow_other_host?
295
+ redirect_to(target, **opts)
296
+ end
297
+
298
+ # Admin- and plugin-declared allowlist of off-site hosts that redirects may target, in addition to the
299
+ # request host. Sourced from the `redirect_allowed_hosts` site option (comma-separated) and the
300
+ # `safe_redirect_hosts` hook (plugins append their provider host to r[:hosts]). Empty by default — the
301
+ # strict same-host policy — so an off-host redirect is only ever reachable once a host is explicitly
302
+ # trusted. Only consulted for an off-host destination, so same-host/relative redirects skip it.
303
+ def cama_redirect_allowed_hosts
304
+ r = { hosts: current_site.get_option('redirect_allowed_hosts', '').to_s.split(',') }
305
+ hooks_run('safe_redirect_hosts', r)
306
+ Array(r[:hosts]).map { |h| h.to_s.strip }.reject(&:blank?)
307
+ end
308
+
309
+ def cama_redirect_host_allowed?(host)
310
+ cama_redirect_allowed_hosts.any? { |allowed| allowed.casecmp?(host) }
311
+ end
312
+
313
+ def cama_redirect_off_host?(url)
314
+ host = URI.parse(url).host
315
+ host.present? && !host.casecmp?(request.host)
316
+ rescue URI::InvalidURIError
317
+ false
318
+ end
319
+
320
+ # allow_other_host is a Rails 7.0+ redirect_to option; on the 6.1 floor there is no open-redirect
321
+ # backstop to satisfy, so an off-host redirect there just works without it.
322
+ def cama_supports_allow_other_host?
323
+ Rails::VERSION::MAJOR >= 7
324
+ end
325
+
170
326
  # calculate the current user for API
171
327
  def cama_calc_api_current_user
172
328
  begin
@@ -178,7 +334,7 @@ module CamaleonCms
178
334
  end
179
335
  return unless doorkeeper_token
180
336
 
181
- current_site.users_include_admins.find_by_id(doorkeeper_token.resource_owner_id).try(:decorate)
337
+ current_site.users_include_admins.find_by(id: doorkeeper_token.resource_owner_id).try(:decorate)
182
338
  end
183
339
  end
184
340
  end