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,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module CamaleonCms
6
+ # SVG content security is now handled by CamaleonCms::SvgContentChecker
7
+ # (Nokogiri XML parse-based detection). These patterns are retained for non-SVG file
8
+ # scanning as a defense-in-depth layer.
9
+ module ContentSecurity
10
+ # Bounded like UserUrlValidator#validate_path_traversal, so multiply-encoded
11
+ # payloads are caught without a decoding bomb looping forever.
12
+ DECODE_PASSES = 5
13
+
14
+ # NUL and C0 controls are stripped before matching: browsers ignore them inside
15
+ # a URI scheme, so "java\0script:" is live markup while the raw bytes match
16
+ # nothing. TAB/LF/CR are deliberately kept -- they are legitimate in text, and
17
+ # the scheme patterns below tolerate them explicitly instead. The C1 range
18
+ # (\x80-\x9f) is deliberately NOT stripped: those bytes are UTF-8 continuation
19
+ # bytes, and removing them would mangle legitimate multibyte text.
20
+ CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
21
+
22
+ # Event-handler attribute stems. Each is matched as `<stem>\w*\s*=`, so a stem covers its
23
+ # whole family (`onmouse` → onmousedown/onmouseover/...).
24
+ UNSAFE_EVENT_HANDLERS = %w[
25
+ onabort onafter onbefore onbegin onblur oncanplay onchange onclick oncontextmenu oncopy oncuechange oncut
26
+ ondblclick ondrag ondrop ondurationchange onend onended onerror onfocus onhashchange oninvalid oninput onkey
27
+ onload onmessage onmouse ononline onoffline onpagehide onpageshow onpage onpaste onpause onplay onpopstate
28
+ onprogress onpropertychange onratechange onreadystatechange onrepeat onreset onresize onscroll onsearch onseek
29
+ onselect onshow onstalled onstorage onsubmit onsuspend ontimeupdate ontoggle onunload onvolumechange onwaiting
30
+ onwheel
31
+ ].freeze
32
+
33
+ # One alternation over all stems instead of 58 separate scans of the full buffer — the same
34
+ # `<stem>\w*\s*=` match, folded into a single pass.
35
+ UNSAFE_EVENT_PATTERN = /(?:#{Regexp.union(UNSAFE_EVENT_HANDLERS).source})\w*\s*=/i
36
+
37
+ # Elements able to navigate, exfiltrate, or load remote active content.
38
+ # Longest-first so "frameset" is preferred over "frame".
39
+ BLOCKED_ELEMENTS = %w[
40
+ script iframe object embed base meta style form applet frameset frame link template portal marquee math
41
+ ].freeze
42
+
43
+ # "/" counts as a tag delimiter, so <script/src="..."> cannot evade the check.
44
+ BLOCKED_ELEMENT_PATTERN = %r{</?(#{Regexp.union(BLOCKED_ELEMENTS).source})[\s/>]}i
45
+
46
+ # Script schemes are dangerous in any URI position.
47
+ SCRIPT_SCHEMES = %w[javascript vbscript].freeze
48
+
49
+ # A browser interprets a data: URI by its media type, so data:image/png;base64,... is an inert
50
+ # bitmap (Inkscape/Figma embed rasters this way and such files must upload), while data:text/html
51
+ # and data:image/svg+xml carry active content. These raster media types are therefore allowed;
52
+ # every other data: URI is blocked.
53
+ DATA_URI_SAFE_MEDIA = %w[
54
+ image/png image/gif image/jpeg image/jpg image/webp image/bmp image/avif
55
+ image/x-icon image/vnd.microsoft.icon
56
+ ].freeze
57
+
58
+ # Characters the URL parser strips from inside a scheme ("jav<TAB>ascript:" executes as
59
+ # javascript:). blocked_scheme_in? deletes these before matching, so the pattern itself stays a
60
+ # cheap contiguous alternation instead of an `[\t\n\r]*` between every character -- the latter
61
+ # backtracked into seconds of CPU on adversarial near-miss input. Deliberately not all of \s: a
62
+ # space is not stripped, so "Sample data : 42" stays prose, never a working URI.
63
+ SCHEME_GAP_CHARS = "\t\n\r"
64
+
65
+ # An allowlisted raster media type ending at a data-URI delimiter (;/,), a markup boundary
66
+ # (whitespace/quote/>/)), or end of input -- so "image/pngx" is not mistaken for "image/png".
67
+ DATA_URI_SAFE_MEDIA_PATTERN = /(?:#{Regexp.union(DATA_URI_SAFE_MEDIA).source})(?=[;,\s"'>)]|\z)/i
68
+
69
+ # javascript:/vbscript: are always blocked. data: is blocked only when it is a functional data URI
70
+ # -- a media type, or a bare ;/, delimiter -- that is NOT an allowlisted raster image; prose that
71
+ # merely contains "...data:" has no such continuation and is left alone. Gaps are deleted by
72
+ # blocked_scheme_in? before this matches, so the scheme and media type are written contiguously.
73
+ BLOCKED_SCHEME_PATTERN = Regexp.new(
74
+ "(?:#{Regexp.union(SCRIPT_SCHEMES).source}):" \
75
+ "|data:(?!#{DATA_URI_SAFE_MEDIA_PATTERN.source})(?:[a-z0-9.+-]+/[a-z0-9.+-]+|[;,])",
76
+ Regexp::IGNORECASE
77
+ )
78
+
79
+ SUSPICIOUS_PATTERNS = [
80
+ UNSAFE_EVENT_PATTERN,
81
+ BLOCKED_ELEMENT_PATTERN
82
+ ].freeze
83
+
84
+ # True if `content` carries a blocked URI scheme. Normalizes (entity/control-char decoding)
85
+ # first -- use this when you hold raw bytes (the SVG scanner). The non-SVG ruleset normalizes
86
+ # once for all its patterns and calls blocked_scheme_in? directly to avoid a second full copy.
87
+ def self.blocked_scheme?(content)
88
+ return false if content.nil?
89
+
90
+ blocked_scheme_in?(normalize(content))
91
+ end
92
+
93
+ # Same test against already-normalized content. The gap bytes a browser strips inside a scheme
94
+ # are removed here so BLOCKED_SCHEME_PATTERN can stay a cheap contiguous match rather than
95
+ # backtracking through an `[\t\n\r]*` between every character.
96
+ def self.blocked_scheme_in?(normalized)
97
+ return false if normalized.nil?
98
+
99
+ normalized.delete(SCHEME_GAP_CHARS).match?(BLOCKED_SCHEME_PATTERN)
100
+ end
101
+
102
+ # Byte-level backstop for markup, immune to the encoding a parser autodetects. A markup parser
103
+ # picks its encoding from in-band signals (a BOM, an XML declaration, a `<meta charset>`) that a
104
+ # browser can resolve differently -- WHATWG maps a `utf-16`/`utf-32` `<meta charset>` back to
105
+ # UTF-8, so a pure-ASCII document declaring utf-16 fires its handlers in the browser while the
106
+ # parser re-decodes the bytes as utf-16 and sees none. Stripping the NUL/C0 padding collapses a
107
+ # UTF-16/UTF-32 (or spoofed-declaration) handler down to the ASCII bytes the browser will run,
108
+ # so the element and handler patterns match it regardless of the declared encoding.
109
+ #
110
+ # Deliberately does NOT entity-decode, unlike `normalize`: character references in a tag or
111
+ # attribute NAME are never decoded by a browser (`on&#99;lick` is an inert attribute, not
112
+ # `onclick`), so decoding would buy no coverage while turning escaped markup (`&lt;script&gt;`,
113
+ # which a browser renders as text) into a false positive. The scheme check keeps using
114
+ # `blocked_scheme?`, which does normalize, because a scheme in an attribute VALUE is decoded.
115
+ def self.suspicious_markup_bytes?(content)
116
+ return false if content.nil?
117
+
118
+ stripped = content.dup.force_encoding(Encoding::BINARY).gsub(CONTROL_CHARS, '')
119
+ UNSAFE_EVENT_PATTERN.match?(stripped) || BLOCKED_ELEMENT_PATTERN.match?(stripped)
120
+ end
121
+
122
+ # Canonicalizes content so encoded variants of a blocked pattern are detected.
123
+ # Returns a normalized copy; the stored file is never modified.
124
+ #
125
+ # Deliberately entity-decoding only, NOT percent-decoding -- unlike
126
+ # UserUrlValidator, which percent-decodes because its input is a URL that gets
127
+ # parsed and resolved, where "%252e%252e" really does become "..". Here the input
128
+ # is file content a browser parses as markup: "&#x61;" really does become "a" in
129
+ # an attribute, but a percent-escape does not. Per the URL spec the scheme is
130
+ # matched literally, so "%6Aavascript:" and "javascript%3Aalert(1)" are not
131
+ # schemes at all and never execute. Percent-decoding here would buy no coverage
132
+ # and would reject legitimate files that merely contain encoded URLs (JSON
133
+ # exports, CSVs of links, access logs).
134
+ def self.normalize(content)
135
+ return content if content.nil?
136
+
137
+ normalized = content.dup.force_encoding(Encoding::BINARY)
138
+ normalized = decode_entities(normalized) if normalized.include?('&')
139
+ normalized = normalized.gsub(CONTROL_CHARS, '') if CONTROL_CHARS.match?(normalized)
140
+ normalized
141
+ end
142
+
143
+ # Repeatedly decodes HTML entities until stable, capped at DECODE_PASSES so a
144
+ # deliberately deep chain cannot spin. Falls back to the last good value if a
145
+ # decode produces bytes that cannot be handled.
146
+ def self.decode_entities(content)
147
+ DECODE_PASSES.times do
148
+ decoded = CGI.unescapeHTML(content).force_encoding(Encoding::BINARY)
149
+ break if decoded == content
150
+
151
+ content = decoded
152
+ end
153
+ content
154
+ rescue ArgumentError, Encoding::CompatibilityError
155
+ content
156
+ end
157
+ end
158
+ end
@@ -14,17 +14,9 @@ require 'dartsass-sprockets'
14
14
  require 'cama_contact_form'
15
15
  require 'cama_meta_tag'
16
16
 
17
- # `factory_bot_rails` gem can be added to test, development and/or other environments in the main app, containing the
18
- # `camaleon_cms` gem.
19
- # So, being unknown, whether it is defined or not, we're trying requiring the gem, using `rescue` in case of failures.
20
- begin
21
- require 'factory_bot_rails'
22
- rescue LoadError
23
- # do nothing
24
- end
25
-
26
17
  $camaleon_engine_dir = File.expand_path('../..', __dir__)
27
18
  require File.join($camaleon_engine_dir, 'lib', 'plugin_routes').to_s
19
+ require File.join($camaleon_engine_dir, 'lib', 'camaleon_cms', 'setup_token').to_s
28
20
  Dir[File.join($camaleon_engine_dir, 'lib', 'ext', '**', '*.rb')].sort.each { |f| require f }
29
21
  require 'draper'
30
22
 
@@ -43,6 +35,16 @@ module CamaleonCms
43
35
  end
44
36
  end
45
37
 
38
+ # Security (audit 2026-08-11 M9): redact credential-bearing parameters from the Rails logs. The site
39
+ # settings form submits the SMTP password and S3 keys in the clear (options[email_pass],
40
+ # options[filesystem_s3_access_key], options[filesystem_s3_secret_key]), as do user passwords, the
41
+ # installer gate token (setup_token, matched by :token) and the stored password of a
42
+ # password-protected post (post[visibility_value]), and the engine set no filter. Appended (+=) so
43
+ # a host app's own filters are preserved.
44
+ initializer 'camaleon_cms.filter_parameters' do |app|
45
+ app.config.filter_parameters += %i[passw email_pass secret access_key token visibility_value]
46
+ end
47
+
46
48
  initializer :append_migrations do |app|
47
49
  engine_dir = File.expand_path('../..', __dir__)
48
50
  translation_files = Dir[File.join($camaleon_engine_dir, 'config', 'locales', '**', '*.{rb,yml}')]
@@ -53,14 +55,15 @@ module CamaleonCms
53
55
  app.config.i18n.load_path.unshift(*translation_files)
54
56
 
55
57
  # assets
56
- app.config.assets.paths << Rails.root.join('app', 'apps')
57
- app.config.assets.paths << Rails.root.join('app', 'assets', 'fonts')
58
+ app.config.assets.paths << Rails.root.join('app/apps')
59
+ app.config.assets.paths << Rails.root.join('app/assets/fonts')
58
60
  app.config.assets.paths << File.join($camaleon_engine_dir, 'app', 'apps')
59
61
  app.config.assets.paths << File.join($camaleon_engine_dir, 'app', 'assets', 'fonts')
60
62
  app.config.encoding = 'utf-8'
61
63
 
62
64
  # add prefix url, like: https://localhost.com/blog/
63
- # config.action_controller.relative_url_root = PluginRoutes.system_info["relative_url_root"] if PluginRoutes.system_info["relative_url_root"].present?
65
+ # config.action_controller.relative_url_root =
66
+ # PluginRoutes.system_info["relative_url_root"] if PluginRoutes.system_info["relative_url_root"].present?
64
67
 
65
68
  # multiple route files
66
69
  app.routes_reloader.paths.push(File.join(engine_dir, 'config', 'routes', 'admin.rb'))
@@ -71,15 +74,25 @@ module CamaleonCms
71
74
  app.config.eager_load_paths += %W[#{app.config.root}/app/apps/]
72
75
  if PluginRoutes.static_system_info['auto_include_migrations']
73
76
  PluginRoutes.all_plugins.each do |plugin|
74
- app.config.paths['db/migrate'] << File.join(plugin['path'], 'migrate') if Dir.exist?(File.join(
75
- plugin['path'], 'migrate'
76
- ))
77
- app.config.paths['db/migrate'] << File.join(plugin['path'], 'db', 'migrate') if Dir.exist?(File.join(
78
- plugin['path'], 'db', 'migrate'
79
- ))
77
+ if Dir.exist?(File.join(plugin['path'], 'migrate'))
78
+ app.config.paths['db/migrate'] << File.join(plugin['path'], 'migrate')
79
+ end
80
+
81
+ if Dir.exist?(File.join(plugin['path'], 'db', 'migrate'))
82
+ app.config.paths['db/migrate'] << File.join(plugin['path'], 'db', 'migrate')
83
+ end
80
84
  end
81
85
  end
82
86
 
87
+ # Security headers for SVG media files. ActionDispatch::Static is only in the host
88
+ # stack when public_file_server is enabled; anchoring on it unconditionally aborts
89
+ # boot on hosts that serve public/ from nginx/Apache.
90
+ if app.config.public_file_server.enabled
91
+ app.middleware.insert_before ::ActionDispatch::Static, CamaleonCms::MediaSecurityHeaders
92
+ else
93
+ app.middleware.use CamaleonCms::MediaSecurityHeaders
94
+ end
95
+
83
96
  # Static files
84
97
  app.middleware.use ::ActionDispatch::Static, "#{root}/public"
85
98
 
@@ -91,9 +104,20 @@ module CamaleonCms
91
104
  end
92
105
  end
93
106
 
94
- if defined?(FactoryBotRails)
95
- config.factory_bot.definition_file_paths +=
96
- [File.expand_path('../../spec/factories', __dir__)]
107
+ # Draw the route table at boot so the first request is never served against a half-built set
108
+ # of routes (see PluginRoutes.draw_routes_eagerly). Closes the development-mode window where
109
+ # the first request after a restart races the multi-site route draw; a no-op once drawn.
110
+ #
111
+ # Runs as a config.after_initialize callback, NOT as a named `initializer ... after:
112
+ # :set_routes_reloader_hook`. Anchoring a CamaleonCms::Engine initializer to that late Finisher
113
+ # hook adds a cross-cutting edge to Rails' initializer tsort that reorders the `append_assets_path`
114
+ # initializers and drops engine/host asset load paths from config.assets.paths -- gem-packaged
115
+ # plugin assets and core camaleon_cms images then raise AssetNotPrecompiledError and 500 the site
116
+ # (regression fixed here). after_initialize runs once the whole boot -- routes reloader, internal
117
+ # routes and every engine's asset path -- is assembled, so the draw still reflects the final route
118
+ # set without perturbing initializer ordering.
119
+ config.after_initialize do
120
+ PluginRoutes.draw_routes_eagerly
97
121
  end
98
122
  end
99
123
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamaleonCms
4
+ class MediaSecurityHeaders
5
+ # Case-insensitive: an `evil.SVG` is stored and served verbatim (get_file_format downcases
6
+ # the extension), so a case-sensitive match left uppercase-extension SVGs served inline
7
+ # without the protective headers.
8
+ SVG_PATH_PATTERN = %r{\A/media/.*\.svg\z}i
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ status, headers, body = @app.call(env)
16
+
17
+ if env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO']&.match?(SVG_PATH_PATTERN)
18
+ # Lowercase header keys: the Rack 3 SPEC requires them and Falcon/Rack::Lint reject
19
+ # mixed case (Puma tolerated it, which hid this).
20
+ headers['x-content-type-options'] = 'nosniff'
21
+ headers['content-security-policy'] = "script-src 'none'"
22
+ end
23
+
24
+ [status, headers, body]
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,69 @@
1
+ require 'securerandom'
2
+ require 'fileutils'
3
+
4
+ module CamaleonCms
5
+ # Gates the first-run installer so setup can be completed only by someone with access to the
6
+ # server's environment or filesystem — not by any anonymous visitor who reaches the app on a fresh
7
+ # deploy (audit finding C1, vector 2). The expected token comes from ENV['CAMALEON_SETUP_TOKEN']
8
+ # when set, otherwise from a generated 0600 file under tmp/; generating it logs its location once so
9
+ # the operator can read it. It is required only while no site exists, and is cleared once one does.
10
+ # See openspec/specs/installer-access-control/spec.md.
11
+ module SetupToken
12
+ ENV_KEY = 'CAMALEON_SETUP_TOKEN'.freeze
13
+
14
+ module_function
15
+
16
+ # The installer is gated only until the first site exists; afterwards it is closed anyway.
17
+ def required?
18
+ CamaleonCms::Site.count == 0
19
+ rescue StandardError
20
+ false
21
+ end
22
+
23
+ # The expected token: an explicit ENV override wins; otherwise a generated file (created on first
24
+ # read). Returns nil only if no ENV value is set and the file cannot be created.
25
+ def value
26
+ env = ENV[ENV_KEY].to_s
27
+ return env if env.present?
28
+
29
+ read_or_generate_file
30
+ end
31
+
32
+ # Constant-time comparison; false for a blank candidate or when no expected token is available.
33
+ def valid?(candidate)
34
+ candidate = candidate.to_s
35
+ expected = value.to_s
36
+ return false if candidate.empty? || expected.empty?
37
+
38
+ ActiveSupport::SecurityUtils.secure_compare(candidate, expected)
39
+ end
40
+
41
+ # Invalidate the generated token once setup has completed (the ENV override, if any, is untouched).
42
+ def clear!
43
+ File.delete(file_path) if File.exist?(file_path)
44
+ rescue StandardError
45
+ nil
46
+ end
47
+
48
+ def file_path
49
+ Rails.root.join('tmp/camaleon_setup_token').to_s
50
+ end
51
+
52
+ def read_or_generate_file
53
+ return File.read(file_path).strip if File.exist?(file_path)
54
+
55
+ token = SecureRandom.hex(32)
56
+ FileUtils.mkdir_p(File.dirname(file_path))
57
+ # 0600 from the first byte (write-then-chmod would leave a world-readable window at default
58
+ # umask), EXCL so a concurrent generator cannot clobber a token already handed to an operator.
59
+ File.open(file_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) { |f| f.write(token) }
60
+ Rails.logger.info("Camaleon CMS: setup token written to #{file_path} — required to run the installer.")
61
+ token
62
+ rescue Errno::EEXIST
63
+ # Lost the creation race between the exist? check and the open: adopt the winner's token.
64
+ read_or_generate_file
65
+ rescue StandardError
66
+ nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamaleonCms
4
+ # Process-wide, boot-time registry of shortcode NAMES. It exists so that save-time code can detect
5
+ # shortcodes without a frontend request: the per-request `CurrentRequest.shortcodes` list is
6
+ # populated by the active frontend theme's `front_before_load` hook and is therefore empty in the
7
+ # admin save path, so the save-time gate consults this canonical set instead.
8
+ #
9
+ # Providers declare their shortcode names through the `register` DSL at boot. Declarations run with
10
+ # NO request/site context, so a declaration MUST NOT touch `current_site`/`request` -- only the
11
+ # names move here; the render-time handler stays in `shortcode_add`. Core declares its own bundled
12
+ # names (see CORE_SHORTCODES); a theme/plugin gem declares its names from its own boot (an
13
+ # initializer or engine), e.g. `CamaleonCms::ShortcodeRegistry.register('redirect')`.
14
+ #
15
+ # Detection mirrors the engine's `ShortCodeHelper#cama_reg_shortcode` regex shape, sourced from
16
+ # this registry rather than `CurrentRequest`, so a name is a match only when followed by a space +
17
+ # attributes or an immediate "]" -- bracketed prose that is not a registered name is never treated
18
+ # as a shortcode.
19
+ #
20
+ # Fail-closed (design D4): an "unavailable" registry -- boot never completed registration -- treats
21
+ # any non-blank content as gated, so a non-administrator is refused rather than let through on an
22
+ # empty set. A legitimately empty registry (available, but no provider declared any name) is a
23
+ # distinct, non-error state that gates nothing.
24
+ class ShortcodeRegistry
25
+ # Core-bundled shortcode names, declared through the DSL at boot. Mirrors the shortcodes that
26
+ # `ShortCodeHelper#shortcodes_init` adds per request; the two are kept in sync by hand (unifying
27
+ # the boot DSL with the render-time `shortcode_add` registration is a deliberate non-goal -- see
28
+ # the gate-content-shortcodes design).
29
+ CORE_SHORTCODES = %w[widget load_libraries asset data].freeze
30
+
31
+ class << self
32
+ # Boot-time DSL: declare one or more shortcode names. Request-independent, idempotent, and
33
+ # order-independent. Blank names are ignored.
34
+ def register(*names)
35
+ names.flatten.each do |name|
36
+ name = name.to_s.strip
37
+ registered_names << name unless name.empty?
38
+ end
39
+ @regexp = nil
40
+ self
41
+ end
42
+
43
+ # Mark the registry as successfully built at boot. Called once registration has completed; if
44
+ # boot raises before this point the registry stays unavailable and detection fails closed.
45
+ def mark_available!
46
+ @available = true
47
+ self
48
+ end
49
+
50
+ def available?
51
+ @available == true
52
+ end
53
+
54
+ # Frozen snapshot of the registered names.
55
+ def names
56
+ registered_names.dup.freeze
57
+ end
58
+
59
+ # Whether `content` contains a registered shortcode. Fails closed when the registry is
60
+ # unavailable (any non-blank content is treated as gated); a legitimately empty registry
61
+ # matches nothing.
62
+ def content_has_shortcode?(content)
63
+ content = content.to_s
64
+ return false if content.empty?
65
+ return true unless available?
66
+
67
+ matcher = regexp
68
+ return false if matcher.nil?
69
+
70
+ matcher.match?(content)
71
+ end
72
+
73
+ # Test-only: reset to the unavailable/empty baseline.
74
+ def reset!
75
+ @registered_names = nil
76
+ @regexp = nil
77
+ @available = false
78
+ self
79
+ end
80
+
81
+ private
82
+
83
+ def registered_names
84
+ @registered_names ||= Set.new
85
+ end
86
+
87
+ # Mirrors ShortCodeHelper#cama_reg_shortcode: "(\\[(a|b)((\s)((?!\\]).)*|)\\])". In that source
88
+ # the "\s" is a literal space (a double-quoted-string escape, not the regex whitespace class),
89
+ # so a code matches only when followed by a literal space + attributes, or an immediate "]".
90
+ # Names are regex-escaped and ordered longest-first so a name that is a prefix of another
91
+ # (e.g. media / media_gallery) still matches its own token.
92
+ def regexp
93
+ return nil if registered_names.empty?
94
+
95
+ @regexp ||= begin
96
+ codes = registered_names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join('|')
97
+ Regexp.new("(\\[(#{codes})((\s)((?!\\]).)*|)\\])")
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamaleonCms
4
+ # Parse-based rejection for uploads a browser parses as markup.
5
+ #
6
+ # Named for SVG because that is where it started; it now serves every markup extension the
7
+ # scanner routes here (see UploaderContentSecurity::MARKUP_EXTENSIONS). The reason is the reason
8
+ # it replaced the regex denylist for SVG: event handlers are rejected *by shape* — any attribute
9
+ # whose name begins with `on` — so no handler name is ever enumerated, and a handler the web
10
+ # platform ships next year is refused without a code change. A name list cannot make that promise;
11
+ # the one in ContentSecurity is missing `onpointer*`, `ontouch*` and `onauxclick` today.
12
+ module SvgContentChecker
13
+ # `animate` and `set` are intentionally absent: SMIL animation elements carry no
14
+ # script by themselves, and their scripting vector is the onbegin/onend/onrepeat
15
+ # attribute, which the element-agnostic on* check below rejects wherever it appears.
16
+ # foreignObject and handler stay banned — they embed foreign markup or handlers.
17
+ #
18
+ # form/meta/base/style/link are valid in SVG and none executes script on its own, but an
19
+ # uploaded SVG is served inline from the site origin and these five are what turn a passive
20
+ # image into markup that can navigate (meta http-equiv=refresh, base href), collect input
21
+ # (form), or pull in remote styling (link, style). This list only ever runs for uploads that are
22
+ # being scanned at all, i.e. from uploaders without `media_unfiltered_upload`, so it needs no
23
+ # trust argument.
24
+ SVG_BANNED_TAGS = %w[
25
+ script foreignObject iframe object embed handler form meta base style link
26
+ ].freeze
27
+
28
+ # What the checker actually refuses: the SVG list above unioned with the generic ruleset's
29
+ # element denylist. The union is load-bearing, not tidiness. ContentSecurity::BLOCKED_ELEMENTS
30
+ # refuses `applet frameset frame template portal marquee math`, which SVG_BANNED_TAGS does not;
31
+ # routing `.html` here while checking only the SVG list would have *narrowed* what an HTML
32
+ # upload is refused for. Unioning means every extension routed here is refused for at least
33
+ # everything the ruleset it came from refused.
34
+ BANNED_TAGS = (SVG_BANNED_TAGS | ContentSecurity::BLOCKED_ELEMENTS).freeze
35
+
36
+ # Extensions parsed as HTML rather than XML. Kept here rather than inferred, because the two
37
+ # modes differ in what a parse failure means (see `parse_document`).
38
+ HTML_MODE = :html
39
+ XML_MODE = :xml
40
+
41
+ # Lowercasing tables for XPath translate(). The tag check folds case so a name an HTML parser
42
+ # lowercases is matched the same as its source form: `Nokogiri::HTML` reports `<foreignObject>`
43
+ # as `foreignobject`, and an SVG inlined into an HTML document fires `<SCRIPT>` exactly as
44
+ # `<script>`. Without folding, `foreignObject` -- the only mixed-case entry in BANNED_TAGS --
45
+ # would be refused as `.svg` (XML, case-sensitive) yet accepted as `.html`.
46
+ ASCII_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
47
+ ASCII_LOWER = 'abcdefghijklmnopqrstuvwxyz'
48
+
49
+ # The banned-element XPath, built once from the frozen BANNED_TAGS rather than reassembled on
50
+ # every scan: `//*[translate(local-name(),UPPER,LOWER)='tag' or ...]`, each tag folded to
51
+ # lowercase so an HTML-lowercased element name still matches.
52
+ BANNED_TAGS_XPATH = "//*[#{BANNED_TAGS.map do |tag|
53
+ "translate(local-name(), '#{ASCII_UPPER}', '#{ASCII_LOWER}') = '#{tag.downcase}'"
54
+ end.join(' or ')}]".freeze
55
+
56
+ module_function
57
+
58
+ # `mode:` defaults to :xml so the pre-existing single-argument call (`unsafe?(content)`) keeps
59
+ # its exact behaviour for any downstream caller.
60
+ def unsafe?(content, mode: XML_MODE)
61
+ return true if content.nil? || content.empty? # rubocop:disable Rails/Blank
62
+
63
+ doc = parse_document(content, mode)
64
+ return true if doc.nil? # XML syntax error -- fail closed
65
+
66
+ # XML mode only. A document that parses to nothing is not a valid document of its format, so
67
+ # refusing it fails closed. HTML has no such signal — Nokogiri::HTML never raises and reports
68
+ # a nil root for input as ordinary as a whitespace-only or comment-only file, which must be
69
+ # accepted. See design.md D3: the guarantee does not exist in HTML mode and is not faked.
70
+ return true if mode != HTML_MODE && doc.root.nil?
71
+
72
+ dangerous_document?(doc, mode)
73
+ end
74
+
75
+ def dangerous_document?(doc, mode)
76
+ return true if doc.xpath(BANNED_TAGS_XPATH).any?
77
+
78
+ # Case-insensitive: XML attribute names are case-sensitive, but an SVG inlined into an HTML
79
+ # document fires ONCLICK/OnClick exactly as onclick, and the non-SVG ruleset already matches
80
+ # handlers case-insensitively. translate() lowercases the "on" prefix so every case variant is
81
+ # caught (no standard attribute name in either format begins with "on" except an event
82
+ # handler). This is the shape match that makes the handler *name* irrelevant.
83
+ return true if doc.xpath('//@*[starts-with(translate(local-name(), "ON", "on"), "on")]').any?
84
+
85
+ # Blocked URI schemes via the shared ContentSecurity.blocked_scheme?, so the markup and generic
86
+ # rulesets cannot drift apart about the same bytes (audit 2026-08-11 NEW-1). Nokogiri decodes
87
+ # char-refs into attr.value but re-emits them in serialization, so the href is matched decoded
88
+ # while the serialized document is entity-decoded (normalized) inside the helper before the
89
+ # scheme match.
90
+ return true if doc.xpath('//@*[local-name() = "href"]').any? do |attr|
91
+ ContentSecurity.blocked_scheme?(attr.value)
92
+ end
93
+
94
+ dangerous_serialization?(doc, mode)
95
+ end
96
+
97
+ # Serialization echoes the document's declared encoding, so a UTF-16/UTF-32 document yields an
98
+ # ASCII-incompatible String. Matching an ASCII regex against it raises
99
+ # Encoding::CompatibilityError, which the rescue below does not catch -- surfacing as a 500
100
+ # on upload instead of a verdict. Force BINARY first: the byte scan stays correct (BANNED_TAGS
101
+ # already caught any <script> element, and normalize strips the interleaved NUL bytes of a
102
+ # non-UTF-8 encoding before the scheme match). Catches DTD entity declarations and CDATA
103
+ # sections, whose expansion is only visible after the parse.
104
+ def dangerous_serialization?(doc, mode)
105
+ serialized = (mode == HTML_MODE ? doc.to_html : doc.to_xml).b
106
+ return true if serialized.match?(/<script[\s>]/i)
107
+
108
+ ContentSecurity.blocked_scheme?(serialized)
109
+ end
110
+
111
+ # The rescue is confined to XML mode on purpose: Nokogiri::HTML never raises, so a shared rescue
112
+ # would be dead code implying a fail-closed guarantee that HTML mode cannot provide.
113
+ def parse_document(content, mode)
114
+ return Nokogiri::HTML(content) if mode == HTML_MODE
115
+
116
+ Nokogiri::XML(content, &:nonet)
117
+ rescue Nokogiri::XML::SyntaxError
118
+ nil
119
+ end
120
+
121
+ private_class_method :dangerous_document?, :dangerous_serialization?, :parse_document
122
+ end
123
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CamaleonCms
4
+ # Output seam for operator-run repair tasks: logs for the record AND prints to
5
+ # stdout, because an operator runs these from a terminal, where Rails.logger
6
+ # writes to a file they are not watching — a logger-only task looks like a
7
+ # no-op. Shared so the reporting rule cannot drift between the repair tasks.
8
+ module TaskReporter
9
+ def self.call(msg)
10
+ Rails.logger.info(msg)
11
+ puts msg # rubocop:disable Rails/Output -- terminal output for the operator is the point
12
+ end
13
+ end
14
+ end