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.
- checksums.yaml +4 -4
- data/README.md +17 -4
- data/Rakefile +3 -16
- data/app/apps/plugins/attack/admin_controller.rb +9 -6
- data/app/apps/plugins/attack/attack_helper.rb +35 -25
- data/app/apps/plugins/authoring_post/authoring_post_helper.rb +23 -16
- data/app/apps/plugins/front_cache/admin_controller.rb +14 -10
- data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
- data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
- data/app/apps/plugins/front_cache/front_cache_helper.rb +109 -37
- data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
- data/app/apps/plugins/visibility_post/config/config.json +1 -0
- data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
- data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
- data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
- data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
- data/app/apps/themes/default/assets/css/style.css.scss +24 -0
- data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
- data/app/apps/themes/default/default_helper.rb +2 -1
- data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
- data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
- data/app/apps/themes/new/custom_helper.rb +5 -9
- data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
- data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
- data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
- data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
- data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
- data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
- data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
- data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
- data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
- data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
- data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
- data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
- data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
- data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
- data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
- data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
- data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
- data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
- data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +80 -13
- data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
- data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
- data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
- data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
- data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
- data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
- data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
- data/app/controllers/camaleon_cms/frontend_controller.rb +146 -74
- data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
- data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
- data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
- data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
- data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
- data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
- data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
- data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
- data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
- data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
- data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
- data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
- data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
- data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
- data/app/decorators/camaleon_cms/post_decorator.rb +68 -50
- data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
- data/app/decorators/camaleon_cms/site_decorator.rb +33 -25
- data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
- data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
- data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
- data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
- data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
- data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
- data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
- data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
- data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
- data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
- data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
- data/app/helpers/camaleon_cms/content_helper.rb +13 -6
- data/app/helpers/camaleon_cms/email_helper.rb +7 -3
- data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
- data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
- data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
- data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
- data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
- data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
- data/app/helpers/camaleon_cms/html_helper.rb +58 -37
- data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
- data/app/helpers/camaleon_cms/session_helper.rb +188 -44
- data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
- data/app/helpers/camaleon_cms/site_helper.rb +34 -13
- data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
- data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
- data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
- data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
- data/app/models/camaleon_cms/ability.rb +15 -7
- data/app/models/camaleon_cms/category.rb +6 -6
- data/app/models/camaleon_cms/custom_field.rb +15 -6
- data/app/models/camaleon_cms/custom_field_group.rb +39 -22
- data/app/models/camaleon_cms/custom_fields_relationship.rb +140 -2
- data/app/models/camaleon_cms/media.rb +8 -2
- data/app/models/camaleon_cms/meta.rb +2 -2
- data/app/models/camaleon_cms/nav_menu.rb +5 -3
- data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
- data/app/models/camaleon_cms/plugin.rb +3 -4
- data/app/models/camaleon_cms/post.rb +137 -27
- data/app/models/camaleon_cms/post_comment.rb +10 -6
- data/app/models/camaleon_cms/post_default.rb +42 -12
- data/app/models/camaleon_cms/post_relationship.rb +6 -4
- data/app/models/camaleon_cms/post_tag.rb +4 -5
- data/app/models/camaleon_cms/post_type.rb +21 -13
- data/app/models/camaleon_cms/site.rb +39 -29
- data/app/models/camaleon_cms/term_relationship.rb +10 -7
- data/app/models/camaleon_cms/term_taxonomy.rb +58 -4
- data/app/models/camaleon_cms/theme.rb +2 -4
- data/app/models/camaleon_cms/user.rb +17 -5
- data/app/models/camaleon_cms/user_role.rb +81 -6
- data/app/models/camaleon_cms/widget/assigned.rb +11 -6
- data/app/models/camaleon_cms/widget/main.rb +12 -8
- data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
- data/app/models/camaleon_record.rb +89 -5
- data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +36 -42
- data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
- data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
- data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
- data/app/models/concerns/camaleon_cms/metas.rb +31 -6
- data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
- data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
- data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
- data/app/models/current_request.rb +8 -1
- data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
- data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
- data/app/uploaders/camaleon_cms_uploader.rb +23 -10
- data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
- data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
- data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
- data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
- data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
- data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
- data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
- data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
- data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
- data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
- data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
- data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
- data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
- data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
- data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
- data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
- data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
- data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
- data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
- data/config/initializers/action_view.rb +42 -5
- data/config/initializers/active_record_extension.rb +13 -27
- data/config/initializers/assets.rb +52 -23
- data/config/initializers/custom_initializers.rb +1 -1
- data/config/locales/camaleon_cms/admin/ar.yml +1 -0
- data/config/locales/camaleon_cms/admin/de.yml +1 -0
- data/config/locales/camaleon_cms/admin/en.yml +36 -0
- data/config/locales/camaleon_cms/admin/es.yml +1 -0
- data/config/locales/camaleon_cms/admin/fr.yml +1 -0
- data/config/locales/camaleon_cms/admin/it.yml +1 -0
- data/config/locales/camaleon_cms/admin/nl.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt.yml +1 -0
- data/config/locales/camaleon_cms/admin/ru.yml +1 -0
- data/config/locales/camaleon_cms/admin/uk.yml +1 -0
- data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
- data/config/routes/admin.rb +33 -15
- data/config/routes/frontend.rb +21 -15
- data/lib/camaleon_cms/assets_precompile.rb +86 -0
- data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
- data/lib/camaleon_cms/content_security.rb +158 -0
- data/lib/camaleon_cms/engine.rb +45 -21
- data/lib/camaleon_cms/media_security_headers.rb +27 -0
- data/lib/camaleon_cms/setup_token.rb +69 -0
- data/lib/camaleon_cms/shortcode_registry.rb +102 -0
- data/lib/camaleon_cms/svg_content_checker.rb +123 -0
- data/lib/camaleon_cms/task_reporter.rb +14 -0
- data/lib/camaleon_cms/unsafe_markup.rb +207 -0
- data/lib/camaleon_cms/uploader_content_security.rb +248 -0
- data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
- data/lib/camaleon_cms/uploader_path_security.rb +146 -0
- data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
- data/lib/camaleon_cms/uploader_support.rb +86 -0
- data/lib/camaleon_cms/version.rb +1 -1
- data/lib/camaleon_cms.rb +23 -0
- data/lib/ext/hash.rb +28 -2
- data/lib/ext/string.rb +10 -5
- data/lib/ext/translator.rb +10 -2
- data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
- data/lib/generators/camaleon_cms/install_generator.rb +7 -4
- data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
- data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
- data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
- data/lib/plugin_routes.rb +412 -368
- data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
- data/lib/tasks/cross_site_field_groups.rake +74 -0
- data/lib/tasks/custom_fields_roles.rake +15 -13
- data/lib/tasks/orphaned_comments.rake +42 -0
- data/lib/tasks/private_upload_acls.rake +31 -0
- data/lib/tasks/site_custom_field_groups.rake +36 -0
- data/lib/tasks/unsafe_stored_content.rake +42 -0
- data/lib/tasks/unsafe_stored_uploads.rake +70 -0
- data/lib/tasks/user_field_groups.rake +27 -0
- 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 (`onclick` is an inert attribute, not
|
|
112
|
+
# `onclick`), so decoding would buy no coverage while turning escaped markup (`<script>`,
|
|
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: "a" 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
|
data/lib/camaleon_cms/engine.rb
CHANGED
|
@@ -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
|
|
57
|
-
app.config.assets.paths << Rails.root.join('app
|
|
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 =
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|