camaleon_cms 2.9.2 → 2.9.4

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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -4
  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 +14 -10
  8. data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
  9. data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
  10. data/app/apps/plugins/front_cache/front_cache_helper.rb +109 -37
  11. data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
  12. data/app/apps/plugins/visibility_post/config/config.json +1 -0
  13. data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
  14. data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
  15. data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
  16. data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
  17. data/app/apps/themes/default/assets/css/style.css.scss +24 -0
  18. data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
  19. data/app/apps/themes/default/default_helper.rb +2 -1
  20. data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
  21. data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
  22. data/app/apps/themes/new/custom_helper.rb +5 -9
  23. data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
  24. data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
  25. data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
  26. data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
  27. data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
  28. data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
  29. data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
  30. data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
  31. data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
  32. data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
  33. data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
  34. data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
  35. data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
  36. data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
  37. data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
  38. data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
  39. data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
  40. data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
  41. data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +80 -13
  42. data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
  43. data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
  44. data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
  45. data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
  46. data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
  47. data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
  48. data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
  49. data/app/controllers/camaleon_cms/frontend_controller.rb +146 -74
  50. data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
  51. data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
  52. data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
  53. data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
  54. data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
  55. data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
  56. data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
  57. data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
  58. data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
  59. data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
  60. data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
  61. data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
  62. data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
  63. data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
  64. data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
  65. data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
  66. data/app/decorators/camaleon_cms/post_decorator.rb +68 -50
  67. data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
  68. data/app/decorators/camaleon_cms/site_decorator.rb +33 -25
  69. data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
  70. data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
  71. data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
  72. data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
  73. data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
  74. data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
  75. data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
  76. data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
  77. data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
  78. data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
  79. data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
  80. data/app/helpers/camaleon_cms/content_helper.rb +13 -6
  81. data/app/helpers/camaleon_cms/email_helper.rb +7 -3
  82. data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
  83. data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
  84. data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
  85. data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
  86. data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
  87. data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
  88. data/app/helpers/camaleon_cms/html_helper.rb +58 -37
  89. data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
  90. data/app/helpers/camaleon_cms/session_helper.rb +188 -44
  91. data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
  92. data/app/helpers/camaleon_cms/site_helper.rb +34 -13
  93. data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
  94. data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
  95. data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
  96. data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
  97. data/app/models/camaleon_cms/ability.rb +15 -7
  98. data/app/models/camaleon_cms/category.rb +6 -6
  99. data/app/models/camaleon_cms/custom_field.rb +15 -6
  100. data/app/models/camaleon_cms/custom_field_group.rb +39 -22
  101. data/app/models/camaleon_cms/custom_fields_relationship.rb +140 -2
  102. data/app/models/camaleon_cms/media.rb +8 -2
  103. data/app/models/camaleon_cms/meta.rb +2 -2
  104. data/app/models/camaleon_cms/nav_menu.rb +5 -3
  105. data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
  106. data/app/models/camaleon_cms/plugin.rb +3 -4
  107. data/app/models/camaleon_cms/post.rb +137 -27
  108. data/app/models/camaleon_cms/post_comment.rb +10 -6
  109. data/app/models/camaleon_cms/post_default.rb +42 -12
  110. data/app/models/camaleon_cms/post_relationship.rb +6 -4
  111. data/app/models/camaleon_cms/post_tag.rb +4 -5
  112. data/app/models/camaleon_cms/post_type.rb +21 -13
  113. data/app/models/camaleon_cms/site.rb +39 -29
  114. data/app/models/camaleon_cms/term_relationship.rb +10 -7
  115. data/app/models/camaleon_cms/term_taxonomy.rb +58 -4
  116. data/app/models/camaleon_cms/theme.rb +2 -4
  117. data/app/models/camaleon_cms/user.rb +17 -5
  118. data/app/models/camaleon_cms/user_role.rb +81 -6
  119. data/app/models/camaleon_cms/widget/assigned.rb +11 -6
  120. data/app/models/camaleon_cms/widget/main.rb +12 -8
  121. data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
  122. data/app/models/camaleon_record.rb +89 -5
  123. data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +36 -42
  124. data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
  125. data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
  126. data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
  127. data/app/models/concerns/camaleon_cms/metas.rb +31 -6
  128. data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
  129. data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
  130. data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
  131. data/app/models/current_request.rb +8 -1
  132. data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
  133. data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
  134. data/app/uploaders/camaleon_cms_uploader.rb +23 -10
  135. data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
  136. data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
  137. data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
  138. data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
  139. data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
  140. data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
  141. data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
  142. data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
  143. data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
  144. data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
  145. data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
  146. data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
  147. data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
  148. data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
  149. data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
  150. data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
  151. data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
  152. data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
  153. data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
  154. data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
  155. data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
  156. data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
  157. data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
  158. data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
  159. data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
  160. data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
  161. data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
  162. data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
  163. data/config/initializers/action_view.rb +42 -5
  164. data/config/initializers/active_record_extension.rb +13 -27
  165. data/config/initializers/assets.rb +52 -23
  166. data/config/initializers/custom_initializers.rb +1 -1
  167. data/config/locales/camaleon_cms/admin/ar.yml +1 -0
  168. data/config/locales/camaleon_cms/admin/de.yml +1 -0
  169. data/config/locales/camaleon_cms/admin/en.yml +36 -0
  170. data/config/locales/camaleon_cms/admin/es.yml +1 -0
  171. data/config/locales/camaleon_cms/admin/fr.yml +1 -0
  172. data/config/locales/camaleon_cms/admin/it.yml +1 -0
  173. data/config/locales/camaleon_cms/admin/nl.yml +1 -0
  174. data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
  175. data/config/locales/camaleon_cms/admin/pt.yml +1 -0
  176. data/config/locales/camaleon_cms/admin/ru.yml +1 -0
  177. data/config/locales/camaleon_cms/admin/uk.yml +1 -0
  178. data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
  179. data/config/routes/admin.rb +33 -15
  180. data/config/routes/frontend.rb +21 -15
  181. data/lib/camaleon_cms/assets_precompile.rb +86 -0
  182. data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
  183. data/lib/camaleon_cms/content_security.rb +158 -0
  184. data/lib/camaleon_cms/engine.rb +45 -21
  185. data/lib/camaleon_cms/media_security_headers.rb +27 -0
  186. data/lib/camaleon_cms/setup_token.rb +69 -0
  187. data/lib/camaleon_cms/shortcode_registry.rb +102 -0
  188. data/lib/camaleon_cms/svg_content_checker.rb +123 -0
  189. data/lib/camaleon_cms/task_reporter.rb +14 -0
  190. data/lib/camaleon_cms/unsafe_markup.rb +207 -0
  191. data/lib/camaleon_cms/uploader_content_security.rb +248 -0
  192. data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
  193. data/lib/camaleon_cms/uploader_path_security.rb +146 -0
  194. data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
  195. data/lib/camaleon_cms/uploader_support.rb +86 -0
  196. data/lib/camaleon_cms/version.rb +1 -1
  197. data/lib/camaleon_cms.rb +23 -0
  198. data/lib/ext/hash.rb +28 -2
  199. data/lib/ext/string.rb +10 -5
  200. data/lib/ext/translator.rb +10 -2
  201. data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
  202. data/lib/generators/camaleon_cms/install_generator.rb +7 -4
  203. data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
  204. data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
  205. data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
  206. data/lib/plugin_routes.rb +412 -368
  207. data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
  208. data/lib/tasks/cross_site_field_groups.rake +74 -0
  209. data/lib/tasks/custom_fields_roles.rake +15 -13
  210. data/lib/tasks/orphaned_comments.rake +42 -0
  211. data/lib/tasks/private_upload_acls.rake +31 -0
  212. data/lib/tasks/site_custom_field_groups.rake +36 -0
  213. data/lib/tasks/unsafe_stored_content.rake +42 -0
  214. data/lib/tasks/unsafe_stored_uploads.rake +70 -0
  215. data/lib/tasks/user_field_groups.rake +27 -0
  216. metadata +125 -4
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'addressable/uri'
4
+
5
+ module CamaleonCms
6
+ # Shared helpers that keep file-upload path handling in a single place so the
7
+ # canonicalization guard and same-site URL detection cannot drift between
8
+ # RuntimeUploaderConcern and UploaderHelper (which both include this module).
9
+ module UploaderPathSecurity
10
+ # Canonicalizes a string path and verifies it stays within the allowed upload
11
+ # roots (the Rails public dir or the system tmp dir). Returns the expanded path
12
+ # when valid, or nil when the path escapes the roots or is otherwise hostile
13
+ # (null bytes, nil).
14
+ #
15
+ # `extra_roots` widens the set for this call only, so trusted application code
16
+ # (plugins, jobs, imports) can stage files elsewhere. It MUST come from
17
+ # application code or operator configuration — never from a request parameter,
18
+ # since Admin::MediaController#crop feeds params[:cp_img_path] into this check and
19
+ # the default roots are what stop it reading arbitrary files.
20
+ def cama_canonical_upload_path(path, extra_roots: [])
21
+ expanded = File.expand_path(path)
22
+ roots = cama_allowed_upload_roots(extra_roots)
23
+ return expanded if roots.any? { |r| expanded == r || expanded.start_with?(r + File::SEPARATOR) }
24
+
25
+ nil
26
+ rescue ArgumentError, TypeError
27
+ nil
28
+ end
29
+
30
+ # The default roots plus any caller-supplied ones, and the private-media directory
31
+ # while the uploader is in private mode (so private files can be cropped without
32
+ # widening the roots for anything else).
33
+ def cama_allowed_upload_roots(extra_roots = [])
34
+ roots = [Rails.public_path.to_s, Dir.tmpdir]
35
+ roots << cama_private_upload_root if cama_private_upload_mode?
36
+ roots.concat(Array.wrap(extra_roots).compact_blank.map { |r| File.expand_path(r.to_s) })
37
+ roots.compact_blank
38
+ end
39
+
40
+ def cama_private_upload_root
41
+ Rails.root.join(CamaleonCmsUploader::PRIVATE_DIRECTORY).to_s
42
+ end
43
+
44
+ def cama_private_upload_mode?
45
+ respond_to?(:cama_uploader, true) && cama_uploader.try(:is_private_uploader?).present?
46
+ rescue StandardError
47
+ false
48
+ end
49
+
50
+ # Reads a caller-supplied root list off an options hash, accepting either key
51
+ # form. Only application code populates this; see cama_canonical_upload_path.
52
+ def cama_extra_upload_roots(options)
53
+ opts = options.try(:to_h) || {}
54
+ Array.wrap(opts[:allowed_roots] || opts['allowed_roots'])
55
+ end
56
+
57
+ # Removes a staged upload file, but only after confirming it canonicalizes
58
+ # inside the given staging root. A bug in the calling code therefore cannot
59
+ # turn into a deletion elsewhere on the filesystem. Returns true when a file
60
+ # was removed.
61
+ def cama_purge_staged_file(path, root)
62
+ return false if path.blank? || root.blank?
63
+ return false unless path_within?(path, root)
64
+
65
+ FileUtils.rm_f(path)
66
+ true
67
+ rescue ArgumentError, TypeError
68
+ false
69
+ end
70
+
71
+ # Upper bound on the decoded byte size of a base64 payload, computed without
72
+ # decoding it, so an oversized upload can be rejected before it is allocated.
73
+ # Overestimates by at most two bytes (padding), erring toward rejecting early.
74
+ def cama_base64_decoded_size(payload)
75
+ payload.to_s.bytesize * 3 / 4
76
+ end
77
+
78
+ # Passes an upload error through untouched, first removing the staging file when
79
+ # this upload owns it (remove_source), so a rejected upload leaves nothing behind
80
+ # in the web-served public/tmp directory. Shared so the cleanup rule cannot drift
81
+ # between RuntimeUploaderConcern and UploaderHelper.
82
+ def cama_upload_failure(error, uploaded_io, settings)
83
+ # The first (malicious-content) rejection in upload_file runs before settings are
84
+ # deep-symbolized, so honor a string-keyed remove_source too — otherwise a rejected upload
85
+ # owning its staging file leaks it in the web-served public/tmp directory.
86
+ remove_source = settings[:remove_source] || settings['remove_source']
87
+ return error unless remove_source
88
+
89
+ cama_purge_staged_file(uploaded_io.try(:path), File.join(Rails.public_path, 'tmp').to_s)
90
+ error
91
+ end
92
+
93
+ # True when the canonicalized path stays strictly inside the given root
94
+ # directory. Used as a defense-in-depth check around write sinks.
95
+ def path_within?(path, root)
96
+ File.expand_path(path).start_with?("#{File.expand_path(root)}#{File::SEPARATOR}")
97
+ rescue ArgumentError, TypeError
98
+ false
99
+ end
100
+
101
+ def same_site_url?(url, site)
102
+ uri = Addressable::URI.parse(url)
103
+ site_uri = Addressable::URI.parse(site.the_url(locale: nil))
104
+ same_host?(uri.host, site_uri.host) && uri.inferred_port == site_uri.inferred_port
105
+ rescue Addressable::URI::InvalidURIError
106
+ false
107
+ end
108
+
109
+ # Case-insensitive host comparison that also ignores a single trailing dot, so
110
+ # a fully-qualified form ("site.com.") still matches the site host ("site.com")
111
+ # instead of being classified as a remote host — which would trigger a needless
112
+ # (and re-validated) outbound fetch to the site itself.
113
+ def same_host?(host_a, host_b)
114
+ return false if host_a.blank? || host_b.blank?
115
+
116
+ host_a.downcase.chomp('.') == host_b.downcase.chomp('.')
117
+ end
118
+
119
+ def site_url_path(url, site)
120
+ uri = Addressable::URI.parse(url)
121
+ path = uri.path.to_s
122
+ # Strip the site's mount subpath (relative_url_root), if any, so that a
123
+ # same-site URL under e.g. "http://host/blog/" maps to public/... and not
124
+ # public/blog/...
125
+ base = Addressable::URI.parse(site.the_url(locale: nil)).path.to_s.chomp('/')
126
+ path = path.sub(%r{\A#{Regexp.escape(base)}(?=/|$)}, '') if base.present?
127
+ strip_locale_prefix(path, site)
128
+ rescue Addressable::URI::InvalidURIError
129
+ url
130
+ end
131
+
132
+ # Removes a leading locale segment (e.g. "/es") on multi-language sites. To
133
+ # avoid mis-stripping a real first directory that merely shares a language
134
+ # code's name, only strip when the stripped path points at an existing file
135
+ # under the public dir; otherwise keep the path as-is.
136
+ def strip_locale_prefix(path, site)
137
+ langs = site.get_languages
138
+ return path unless langs.size > 1
139
+
140
+ stripped = path.sub(%r{\A/(?:#{Regexp.union(langs.map(&:to_s))})(?=/|$)}, '')
141
+ return path if stripped == path
142
+
143
+ File.exist?(File.expand_path(File.join(Rails.public_path, stripped))) ? stripped : path
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'tempfile'
5
+
6
+ module CamaleonCms
7
+ # Staging and persistence pipeline shared by the two uploader entry points:
8
+ # CamaleonCms::RuntimeUploaderConcern (controllers) and CamaleonCms::UploaderHelper
9
+ # (views, ActiveJobs, standalone objects). Both include this module, so a fix to
10
+ # upload staging or persistence cannot land in one entry point and not the other.
11
+ #
12
+ # Nothing here may read request state such as `params`: UploaderHelper is documented
13
+ # in config/initializers/custom_initializers.rb as includable from an ActiveJob, where
14
+ # no request exists. Context differences go through the message seam at the bottom.
15
+ module UploaderPipeline
16
+ # upload a file into server
17
+ # settings:
18
+ # folder: Directory where the file will be saved (default: "")
19
+ # sample: temporal => will save in /rails_path/public/temporal
20
+ # generate_thumb: true, # generate thumb image if this is image format (default true)
21
+ # maximum: maximum bytes permitted to upload (default: 1000MG)
22
+ # dimension: dimension for the image (sample: 30x30 | x30 | 30x | 300x300?)
23
+ # formats: extensions permitted, sample: jpg,png,... or generic: images | videos | audios | documents (default *)
24
+ # remove_source: Boolean (delete source file after saved if this is true, default false)
25
+ # same_name: Boolean (save the file with the same name if defined true, else search for a non used name)
26
+ # versions: (String) Create additional multiple versions of the image uploaded,
27
+ # sample: '300x300,505x350' ==> Will create two extra images with these dimensions
28
+ # sample "test.png", versions: '200x200,450x450' will generate: thumb/test-png_200x200.png, test-png_450x450.png
29
+ # thumb_size: String (redefine the dimensions of the thumbnail, sample: '100x100' ==> only for images)
30
+ # temporal_time: if great than 0 seconds, then this file will expire (removed) in that time (default: 0)
31
+ # To manage jobs, please check https://edgeguides.rubyonrails.org/active_job_basics.html
32
+ # Note: if you are using temporal_time, you will need to copy the file to another directory later
33
+ # sample: upload_file(params[:my_file], {formats: "images", folder: "temporal"})
34
+ # sample: upload_file(params[:my_file], {formats: "jpg,png,gif,mp3,mp4",
35
+ # temporal_time: 10.minutes, maximum: 10.megabytes})
36
+ def upload_file(uploaded_io, settings = {})
37
+ cached_name = uploaded_io.is_a?(ActionDispatch::Http::UploadedFile) ? uploaded_io.original_filename : nil
38
+ return { error: 'File is empty', file: nil, size: nil } if uploaded_io.blank?
39
+
40
+ if uploaded_io.is_a?(String) && uploaded_io.match(%r{^https?://}).present? # download url file
41
+ tmp = cama_tmp_upload(uploaded_io)
42
+ return tmp if tmp[:error].present?
43
+
44
+ settings[:remove_source] = true
45
+ uploaded_io = tmp[:file_path]
46
+ end
47
+ if uploaded_io.is_a?(String)
48
+ expanded = cama_canonical_upload_path(uploaded_io, extra_roots: cama_extra_upload_roots(settings))
49
+ return { error: 'Invalid file path' } unless expanded
50
+
51
+ uploaded_io = expanded
52
+ end
53
+ uploaded_io = File.open(uploaded_io) if uploaded_io.is_a?(String)
54
+ if settings[:dimension].present?
55
+ uploaded_io = File.open(cama_resize_upload(uploaded_io.path, settings[:dimension]))
56
+ end
57
+
58
+ # Permission first: a trusted upload never reads the file in order to scan it.
59
+ if !cama_trusted_for_unfiltered_upload? && file_content_unsafe?(uploaded_io)
60
+ return cama_upload_failure({ error: 'Potentially malicious content found!' }, uploaded_io, settings)
61
+ end
62
+
63
+ settings = settings.to_h.deep_symbolize_keys
64
+ settings[:uploaded_io] = uploaded_io
65
+ settings = {
66
+ folder: '',
67
+ maximum: current_site.get_option('filesystem_max_size', 100).to_f.megabytes,
68
+ formats: '*',
69
+ generate_thumb: true,
70
+ temporal_time: 0,
71
+ filename: begin
72
+ cached_name || uploaded_io.original_filename
73
+ rescue StandardError
74
+ uploaded_io.path.split('/').last
75
+ end.cama_fix_filename,
76
+ file_size: File.size(uploaded_io.to_io),
77
+ remove_source: false,
78
+ same_name: false,
79
+ versions: '',
80
+ thumb_size: nil
81
+ }.merge!(settings)
82
+ settings[:formats] = '*' if settings[:formats].nil?
83
+ settings[:folder] = '' if settings[:folder].nil? # e.g. crop_url passes no folder
84
+ io_before_hook = settings[:uploaded_io]
85
+ hooks_run('before_upload', settings)
86
+
87
+ # A before_upload handler may rebind settings[:uploaded_io] to bytes the top-of-method
88
+ # scan never saw (e.g. an image optimizer rewriting an SVG). For an untrusted uploader,
89
+ # re-scan the substituted IO so a handler cannot launder a blocked payload past the scan.
90
+ # Keyed on object identity: an unchanged IO was already scanned, and a permission-holder
91
+ # is exempt exactly as above.
92
+ if !settings[:uploaded_io].equal?(io_before_hook) && !cama_trusted_for_unfiltered_upload? &&
93
+ file_content_unsafe?(settings[:uploaded_io])
94
+ return cama_upload_failure({ error: 'Potentially malicious content found!' }, settings[:uploaded_io], settings)
95
+ end
96
+
97
+ # guard against path traversal
98
+ unless cama_uploader.valid_folder_path?(settings[:folder])
99
+ return cama_upload_failure({ error: 'Invalid file path' }, uploaded_io, settings)
100
+ end
101
+
102
+ # formats validations
103
+ err = validate_file_format_or_error(uploaded_io.path, settings[:formats])
104
+ return cama_upload_failure(err, uploaded_io, settings) if err
105
+
106
+ # file size validations
107
+ err = cama_size_limit_error(settings[:file_size], settings[:maximum])
108
+ return cama_upload_failure(err, uploaded_io, settings) if err
109
+
110
+ # save file
111
+ key = File.join(settings[:folder], settings[:filename]).to_s.cama_fix_slash
112
+ res = cama_uploader.add_file(settings[:uploaded_io], key, { same_name: settings[:same_name] })
113
+
114
+ # generate image versions
115
+ if res['file_type'] == 'image'
116
+ settings[:versions].to_s.delete(' ').split(',').each do |v|
117
+ version_path = cama_resize_upload(settings[:uploaded_io].path, v, { replace: false })
118
+ cama_uploader.add_file(version_path, cama_uploader.version_path(res['key'], v), is_thumb: true,
119
+ same_name: true)
120
+ FileUtils.rm_f(version_path)
121
+ end
122
+ end
123
+
124
+ # generate thumb
125
+ if settings[:generate_thumb] && res['thumb'].present?
126
+ cama_uploader_generate_thumbnail(uploaded_io.path, res['key'], settings[:thumb_size],
127
+ settings[:remove_source])
128
+ end
129
+ FileUtils.rm_f(uploaded_io.path) if settings[:remove_source] && File.exist?(uploaded_io.path)
130
+
131
+ hooks_run('after_upload', settings)
132
+
133
+ # temporal file upload (always put as local for temporal files)
134
+ CamaleonCmsUploader.delete_block.call(settings, cama_uploader, key) if settings[:temporal_time] > 0
135
+
136
+ res
137
+ end
138
+
139
+ # upload tmp file
140
+ # support for url and local path
141
+ # sample:
142
+ # cama_tmp_upload('https://camaleon.website/media/132/logo2.png') ==> /var/rails/my_project/public/tmp/1/logo2.png
143
+ # cama_tmp_upload('/var/www/media/132/logo 2.png') ==> /var/rails/my_project/public/tmp/1/logo-2.png
144
+ # accept args:
145
+ # name: to indicate the name to use,
146
+ # sample: cama_tmp_upload('/var/www/media/132/logo 2.png', {name: 'owen.png', formats: 'images'})
147
+ # formats: extensions permitted, sample: jpg,png,... or generic: images | videos | audios | documents (default *)
148
+ # dimension: 20x30
149
+ # return: {file_path, error}
150
+ def cama_tmp_upload(uploaded_io, args = {})
151
+ tmp_path = args[:path] || File.join(Rails.public_path, 'tmp', current_site.id.to_s).to_s
152
+ FileUtils.mkdir_p(tmp_path)
153
+ # Default to the site limit so the size guard below actually applies: callers
154
+ # such as crop/crop_url pass no :maximum, which left it dead code.
155
+ args[:maximum] ||= current_site.get_option('filesystem_max_size', 100).to_f.megabytes
156
+ saved = false
157
+ downloaded_tmp_file = nil
158
+ staged_path = nil
159
+ if uploaded_io.is_a?(String) && uploaded_io.start_with?('data:') # create tmp file using base64 format
160
+ path, err = cama_stage_data_uri(uploaded_io, args, tmp_path)
161
+ return err if err
162
+
163
+ staged_path = path
164
+ _tmp_name = File.basename(args[:name].to_s)
165
+ uploaded_io = File.open(path)
166
+ saved = true
167
+ elsif uploaded_io.is_a?(String) && uploaded_io.start_with?('http://', 'https://')
168
+ err = validate_file_format_or_error(uploaded_io, args[:formats])
169
+ return err if err
170
+
171
+ if same_site_url?(uploaded_io, current_site)
172
+ uploaded_io = File.join(Rails.public_path, site_url_path(uploaded_io, current_site)).to_s
173
+ else
174
+ remote_file = cama_download_remote_file(uploaded_io)
175
+ return remote_file if remote_file[:error].present?
176
+
177
+ downloaded_tmp_file = remote_file[:file]
178
+ uploaded_io = downloaded_tmp_file
179
+ end
180
+ _tmp_name = if uploaded_io.is_a?(String)
181
+ uploaded_io.split('/').last.split('?').first
182
+ else
183
+ uploaded_io.path.split('/').last
184
+ end
185
+ args[:name] = args[:name] || _tmp_name
186
+ end
187
+ if uploaded_io.is_a?(String)
188
+ expanded = cama_canonical_upload_path(uploaded_io, extra_roots: cama_extra_upload_roots(args))
189
+ return { error: 'Invalid file path' } unless expanded
190
+
191
+ uploaded_io = expanded
192
+ end
193
+ uploaded_io = File.open(uploaded_io) if uploaded_io.is_a?(String)
194
+ err = validate_file_format_or_error(_tmp_name || uploaded_io.path, args[:formats])
195
+ return err if err
196
+
197
+ actual_size = begin
198
+ uploaded_io.size
199
+ rescue StandardError
200
+ File.size(uploaded_io)
201
+ end
202
+ err = cama_size_limit_error(actual_size, args[:maximum])
203
+ return err if err
204
+
205
+ name = args[:name] || uploaded_io&.original_filename || uploaded_io.path.split('/').last
206
+ name = "#{File.basename(name, File.extname(name)).parameterize}#{File.extname(name)}"
207
+ path ||= uploader_verify_name(File.join(tmp_path, name))
208
+ unless saved
209
+ # Same rule as the data: branch above -- read, scan, and only then write, so no
210
+ # source can land unscanned in public/tmp. The scan is keyed on `name`, the output
211
+ # filename, because that is the extension the web server will serve the bytes under;
212
+ # only the uploader's permission decides whether it runs.
213
+ content = uploaded_io.read
214
+ if !cama_trusted_for_unfiltered_upload? && content_unsafe?(content, filename: name)
215
+ return { error: 'Potentially malicious content found!' }
216
+ end
217
+
218
+ File.open(path, 'wb') { |f| f.write(content) }
219
+ staged_path = path
220
+ end
221
+ path = cama_resize_upload(path, args[:dimension]) if args[:dimension].present?
222
+ { file_path: path, error: nil }
223
+ rescue StandardError
224
+ # A raised error leaves no half-written file behind in the served staging dir.
225
+ cama_purge_staged_file(staged_path, tmp_path)
226
+ raise
227
+ ensure
228
+ downloaded_tmp_file&.close!
229
+ end
230
+
231
+ # Message seam. Rendering user-facing upload errors differs by execution context,
232
+ # so the pipeline never calls a translator directly.
233
+ #
234
+ # `ct` runs the `on_translation` hook that lets plugins override the text, which a
235
+ # shared I18n.t call would silently drop. CamaleonCms::UploaderHelper overrides all
236
+ # three to route through `ct` / `cama_t` / `number_to_human_size` (it includes
237
+ # CamaleonHelper itself). CamaleonCms::RuntimeUploaderConcern overrides only
238
+ # cama_uploader_ct, routing through `ct` when its host has one — true for
239
+ # CamaleonCms::CamaleonController since #1223 restored CamaleonHelper there. The
240
+ # defaults below are what remains: `t`/`human_size` on the concern path, and all
241
+ # three for concern hosts without `ct` (ActiveJobs, standalone objects).
242
+ def cama_uploader_ct(key, args = {})
243
+ I18n.t("camaleon_cms.common.#{key}", **args)
244
+ end
245
+
246
+ def cama_uploader_t(key, args = {})
247
+ I18n.t(key, **args)
248
+ end
249
+
250
+ def cama_uploader_human_size(bytes)
251
+ ActiveSupport::NumberHelper.number_to_human_size(bytes)
252
+ end
253
+
254
+ private
255
+
256
+ # Download remote files with SSRF guardrails: validate host/IP and reject redirects.
257
+ # NOTE: UserUrlValidator.validate is intentionally called here even though the
258
+ # crop_url controller path may have already validated the URL — this ensures
259
+ # every caller of cama_tmp_upload is protected (defense-in-depth).
260
+ def cama_download_remote_file(url)
261
+ validator = UserUrlValidator.new
262
+ validation_result = validator.validate(url, reject_path_traversal: true)
263
+ return { error: validation_result.join(', ') } if validation_result.is_a?(Array)
264
+
265
+ uri = URI.parse(url)
266
+ http = Net::HTTP.new(uri.host, uri.port)
267
+ http.use_ssl = uri.scheme == 'https'
268
+ # Pin the socket to the exact IP the validator vetted so a hostname whose DNS
269
+ # answer changes between the SSRF check and the fetch (DNS rebinding) cannot
270
+ # redirect the request to an internal address; SNI/cert checks keep the host.
271
+ http.ipaddr = validator.resolved_ip if validator.resolved_ip
272
+ http.open_timeout = 10
273
+ http.read_timeout = 10
274
+ response = http.start do |conn|
275
+ conn.request(Net::HTTP::Get.new(uri.request_uri.presence || '/'))
276
+ end
277
+
278
+ return { error: 'Redirects are not allowed for remote uploads.' } if response.is_a?(Net::HTTPRedirection)
279
+
280
+ unless response.is_a?(Net::HTTPSuccess)
281
+ return { error: "Unable to download remote file (HTTP #{response.code})." }
282
+ end
283
+
284
+ # Enforce the site's maximum upload size to prevent memory exhaustion from oversized responses.
285
+ max_bytes = current_site.get_option('filesystem_max_size', 100).to_f.megabytes
286
+ body = response.body
287
+ if body.bytesize > max_bytes
288
+ return { error: "Remote file too large (max #{ActiveSupport::NumberHelper.number_to_human_size(max_bytes)})." }
289
+ end
290
+
291
+ ext = File.extname(uri.path.to_s)
292
+ tempfile = Tempfile.new(['cama-upload-url', ext], binmode: true)
293
+ tempfile.write(body)
294
+ tempfile.rewind
295
+ { file: tempfile, error: nil }
296
+ rescue StandardError => e
297
+ { error: "Unable to download remote file: #{ERB::Util.html_escape(e.message)}" }
298
+ end
299
+
300
+ def validate_file_format_or_error(file, formats)
301
+ return if cama_uploader.class.validate_file_format(file, formats)
302
+
303
+ { error: "#{cama_uploader_ct('file_format_error')} (#{formats})" }
304
+ end
305
+
306
+ # Stages a base64 data: payload into tmp_path, bounding its size and scanning its
307
+ # content BEFORE the write, so nothing oversized or hostile ever reaches a path
308
+ # the web server would hand out. Returns [path, error]; exactly one is non-nil.
309
+ def cama_stage_data_uri(uploaded_io, args, tmp_path)
310
+ return [nil, { error: cama_uploader_t('camaleon_cms.admin.media.name_required').to_s }] if args[:name].blank?
311
+
312
+ # Strip any directory components so a hostile name (e.g. "../../etc/x")
313
+ # cannot escape tmp_path when the base64 payload is written below.
314
+ tmp_name = File.basename(args[:name].to_s)
315
+ err = validate_file_format_or_error(tmp_name, args[:formats])
316
+ return [nil, err] if err
317
+
318
+ payload = uploaded_io.split(';base64,').last
319
+ # Bound the payload before decoding, so an oversized upload is never allocated
320
+ # in full nor written into the served staging directory.
321
+ err = cama_size_limit_error(cama_base64_decoded_size(payload), args[:maximum])
322
+ return [nil, err] if err
323
+
324
+ # The decoded bytes are already in memory here, so scanning costs no extra copy.
325
+ decoded = Base64.decode64(payload)
326
+ if !cama_trusted_for_unfiltered_upload? && content_unsafe?(decoded, filename: tmp_name)
327
+ return [nil, { error: 'Potentially malicious content found!' }]
328
+ end
329
+
330
+ path = uploader_verify_name(File.join(tmp_path, tmp_name))
331
+ return [nil, { error: 'Invalid file path' }] unless path_within?(path, tmp_path)
332
+
333
+ File.open(path, 'wb') { |f| f.write(decoded) }
334
+ [path, nil]
335
+ end
336
+
337
+ # Returns an error hash when size exceeds maximum, nil otherwise.
338
+ # A non-positive maximum means "no limit" — a site whose stored filesystem_max_size is blank
339
+ # or 0 (also reached by plugin callers passing that option as :maximum) would otherwise fail
340
+ # every upload with "File size exceeded (0 Bytes)". The size comparison coerces for the same
341
+ # reason: a caller-supplied :maximum can be a numeric String, and a bare `String >= Integer`
342
+ # raises instead of enforcing the limit.
343
+ def cama_size_limit_error(size, maximum)
344
+ return if maximum.blank? || maximum.to_f <= 0 || maximum.to_f >= size
345
+
346
+ max_size = cama_uploader_human_size(maximum)
347
+ { error: "#{cama_uploader_ct('file_size_exceeded', default: 'File size exceeded')} (#{max_size})" }
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamaleonCms
4
+ # Uploader backend resolution plus the filename and URL helpers that the staging
5
+ # and image-processing pipelines build on. Shared by the two uploader entry points,
6
+ # CamaleonCms::RuntimeUploaderConcern and CamaleonCms::UploaderHelper.
7
+ module UploaderSupport
8
+ # return the current uploader
9
+ def cama_uploader
10
+ @cama_uploader ||= lambda {
11
+ thumb = current_site.get_option('filesystem_thumb_size', '100x100').split('x')
12
+ args = {
13
+ server: current_site.get_option('filesystem_type', 'local').downcase,
14
+ thumb: { w: thumb[0], h: thumb[1] },
15
+ aws_settings: {
16
+ region: current_site.get_option('filesystem_region', 'us-west-2'),
17
+ access_key: current_site.get_option('filesystem_s3_access_key'),
18
+ secret_key: current_site.get_option('filesystem_s3_secret_key'),
19
+ bucket: current_site.get_option('filesystem_s3_bucket_name'),
20
+ cloud_front: current_site.get_option('filesystem_s3_cloudfront'),
21
+ # permit to add your custom attributes for
22
+ # file_upload https://docs.aws.amazon.com/sdkforruby/api/Aws/S3/Object.html#upload_file-instance_method
23
+ aws_file_upload_settings: ->(settings) { settings },
24
+ # permit to read custom attributes from aws file and add to file parsed object
25
+ aws_file_read_settings: ->(data, _s3_file) { data }
26
+ },
27
+ custom_uploader: nil # possibility to use custom file uploader
28
+ }
29
+ hooks_run('on_uploader', args)
30
+ return args[:custom_uploader] if args[:custom_uploader].present?
31
+
32
+ base_args = { current_site: current_site, thumb: args[:thumb] }
33
+ case args[:server]
34
+ when 's3', 'aws'
35
+ CamaleonCmsAwsUploader.new(base_args.merge(aws_settings: args[:aws_settings]), self)
36
+ else
37
+ CamaleonCmsLocalUploader.new(base_args, self)
38
+ end
39
+ }.call
40
+ end
41
+
42
+ # helper to find an available filename for file_path in that directory
43
+ # sample: uploader_verify_name("/var/www/my_image.jpg")
44
+ # return "/var/www/my_image_1.jpg" => if "/var/www/my_image.jpg" exist
45
+ # return "/var/www/my_image.jpg" => if "/var/www/my_image.jpg" doesn't exist
46
+ def uploader_verify_name(file_path)
47
+ dir = File.dirname(file_path)
48
+ filename = File.basename(file_path).to_s.cama_fix_filename
49
+ files = Dir.entries(dir)
50
+ if files.include?(filename)
51
+ i = 1
52
+ _filename = filename
53
+ while files.include?(_filename)
54
+ _filename = "#{File.basename(filename, File.extname(filename))}_#{i}#{File.extname(filename)}"
55
+ i += 1
56
+ end
57
+ filename = _filename
58
+ end
59
+ "#{File.dirname(file_path)}/#{filename}"
60
+ end
61
+
62
+ # convert downloaded file path into public url
63
+ def cama_file_path_to_url(file_path)
64
+ file_path.sub(Rails.public_path.to_s, begin
65
+ root_url
66
+ rescue StandardError
67
+ cama_root_url
68
+ end)
69
+ end
70
+
71
+ # convert public url to file path
72
+ def cama_url_to_file_path(url)
73
+ File.join(Rails.public_path, URI(url.to_s).path)
74
+ end
75
+
76
+ def slugify(val)
77
+ val.to_s.downcase.strip.tr(' ', '-').gsub(/[^\w-]/, '')
78
+ end
79
+
80
+ def slugify_folder(val)
81
+ split_folder = val.split('/')
82
+ split_folder[-1] = slugify(split_folder[-1])
83
+ split_folder.join('/')
84
+ end
85
+ end
86
+ end
@@ -1,3 +1,3 @@
1
1
  module CamaleonCms
2
- VERSION = '2.9.2'.freeze
2
+ VERSION = '2.9.4'.freeze
3
3
  end
data/lib/camaleon_cms.rb CHANGED
@@ -1,5 +1,28 @@
1
1
  require 'camaleon_cms/engine'
2
2
  require 'camaleon_cms/version'
3
+ require 'camaleon_cms/content_security'
4
+ require 'camaleon_cms/unsafe_markup'
5
+ require 'camaleon_cms/svg_content_checker'
6
+ require 'camaleon_cms/media_security_headers'
7
+ require 'camaleon_cms/uploader_content_security'
8
+ require 'camaleon_cms/uploader_path_security'
9
+ require 'camaleon_cms/uploader_pipeline'
10
+ require 'camaleon_cms/uploader_image_processing'
11
+ require 'camaleon_cms/uploader_support'
12
+ require 'camaleon_cms/captcha_image_generation'
13
+ require 'camaleon_cms/task_reporter'
14
+ require 'camaleon_cms/shortcode_registry'
3
15
 
4
16
  module CamaleonCms
5
17
  end
18
+
19
+ # Declare core's bundled shortcode names through the boot DSL, then mark the registry available. This
20
+ # runs at require time (before any request), so the save-time shortcode gate can detect shortcodes
21
+ # with no frontend request. If this ever raises the registry stays unavailable and the gate fails
22
+ # closed (non-administrators are refused) rather than fail open on an empty set.
23
+ begin
24
+ CamaleonCms::ShortcodeRegistry.register(*CamaleonCms::ShortcodeRegistry::CORE_SHORTCODES)
25
+ CamaleonCms::ShortcodeRegistry.mark_available!
26
+ rescue StandardError => e
27
+ warn("CamaleonCms::ShortcodeRegistry boot registration failed (gate fails closed): #{e.message}")
28
+ end
data/lib/ext/hash.rb CHANGED
@@ -1,18 +1,44 @@
1
+ require 'cgi'
2
+
1
3
  class Hash
4
+ # Attribute names are emitted verbatim, so they are restricted to a conservative subset of what
5
+ # HTML allows rather than escaped: escaping cannot help here, because the characters that turn one
6
+ # name into two (whitespace, `=`, `/`, quotes) are not HTML metacharacters. Covers the forms
7
+ # actually used - `id`, `class`, `data-*`, `aria-*`, `xml:lang`.
8
+ CAMA_HTML_ATTR_NAME = /\A[a-zA-Z_:][-a-zA-Z0-9_:.]*\z/
9
+
2
10
  # convert hash to string like class="class val" name='name val'
11
+ #
12
+ # Values are escaped for the HTML attribute context, so no value can close its own attribute and
13
+ # introduce another. `CGI.escapeHTML` is used rather than `ERB::Util.html_escape` deliberately: the
14
+ # latter is a no-op on an `html_safe` value, which would let a caller pass a string whose quote
15
+ # survives into the output. Callers of this method are building attribute values, never markup, so
16
+ # unconditional escaping is the correct contract.
17
+ #
18
+ # Pairs whose key is not a valid attribute name are dropped. A key such as `x onfocus=alert(1) y`
19
+ # would otherwise render as three attributes, so there is no safe way to emit it; anything with a
20
+ # space in it was already producing malformed markup before this guard existed.
3
21
  def to_attr_format(split = ' ')
4
22
  res = []
5
23
  each do |key, value|
6
- res << "#{key} = \"#{value.to_s.gsub('"', '\"')}\""
24
+ next unless key.to_s.match?(CAMA_HTML_ATTR_NAME)
25
+
26
+ res << "#{key} = \"#{CGI.escapeHTML(value.to_s)}\""
7
27
  end
8
28
  res.join(split)
9
29
  end
10
30
 
11
31
  # convert hash to attributes for url_path
32
+ #
33
+ # Emits a Ruby fragment (`:key => "value"`) for code generation, not HTML, so it must NOT use the
34
+ # HTML escaper above — entity-encoding would corrupt the generated code. `inspect` produces a
35
+ # complete, correctly escaped double-quoted literal; the previous hand-rolled escape handled the
36
+ # quote but not the backslash, so a value like `a\b` became a backspace and `a\"b` produced a
37
+ # fragment that would not parse.
12
38
  def to_attr_url_format
13
39
  res = []
14
40
  each do |key, value|
15
- res << ":#{key} => \"#{value.to_s.gsub('"', '\"')}\""
41
+ res << ":#{key} => #{value.to_s.inspect}"
16
42
  end
17
43
  res.join ','
18
44
  end