decidim-core 0.32.1 → 0.32.2

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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/content_blocks/participatory_space_main_data/title.erb +1 -14
  3. data/app/cells/decidim/following_cell.rb +1 -1
  4. data/app/cells/decidim/member/show.erb +1 -1
  5. data/app/cells/decidim/nav_links/show.erb +3 -1
  6. data/app/cells/decidim/share_text_widget/modal.erb +1 -0
  7. data/app/cells/decidim/share_widget/modal.erb +1 -0
  8. data/app/cells/decidim/share_widget/qr_code_modal.erb +2 -4
  9. data/app/commands/decidim/create_omniauth_registration.rb +17 -2
  10. data/app/controllers/concerns/decidim/devise_authentication_methods.rb +4 -0
  11. data/app/controllers/concerns/decidim/devise_controllers.rb +2 -0
  12. data/app/controllers/concerns/decidim/disable_redirection_to_external_host.rb +2 -0
  13. data/app/controllers/concerns/decidim/filter_resource.rb +2 -0
  14. data/app/controllers/concerns/decidim/impersonate_users.rb +2 -2
  15. data/app/controllers/concerns/decidim/locale_switcher.rb +12 -10
  16. data/app/controllers/concerns/decidim/needs_permission.rb +22 -10
  17. data/app/controllers/concerns/decidim/onboarding_action_methods.rb +2 -0
  18. data/app/controllers/concerns/decidim/paginable.rb +7 -3
  19. data/app/controllers/concerns/decidim/payload_info.rb +2 -0
  20. data/app/controllers/concerns/decidim/resource_versions_concern.rb +14 -0
  21. data/app/controllers/decidim/application_controller.rb +0 -6
  22. data/app/controllers/decidim/authorization_modals_controller.rb +2 -0
  23. data/app/controllers/decidim/components/base_controller.rb +2 -0
  24. data/app/controllers/decidim/devise/passwords_controller.rb +8 -0
  25. data/app/controllers/decidim/favicon_controller.rb +9 -0
  26. data/app/controllers/decidim/free_resource_authorization_modals_controller.rb +2 -0
  27. data/app/controllers/decidim/gamification/badges_controller.rb +2 -0
  28. data/app/controllers/decidim/homepage_controller.rb +4 -1
  29. data/app/controllers/decidim/likes_controller.rb +1 -1
  30. data/app/controllers/decidim/links_controller.rb +2 -0
  31. data/app/controllers/decidim/manifests_controller.rb +2 -0
  32. data/app/controllers/decidim/messaging/conversations_controller.rb +10 -4
  33. data/app/controllers/decidim/newsletters_controller.rb +4 -0
  34. data/app/controllers/decidim/notifications_subscriptions_controller.rb +4 -0
  35. data/app/controllers/decidim/offline_controller.rb +5 -1
  36. data/app/controllers/decidim/open_data_controller.rb +5 -1
  37. data/app/controllers/decidim/pages_controller.rb +31 -3
  38. data/app/controllers/decidim/private_downloads_controller.rb +2 -0
  39. data/app/controllers/decidim/profiles_controller.rb +19 -1
  40. data/app/controllers/decidim/qr_controller.rb +3 -1
  41. data/app/controllers/decidim/resource_autocomplete_controller.rb +2 -0
  42. data/app/controllers/decidim/searches_controller.rb +2 -0
  43. data/app/controllers/decidim/short_links_controller.rb +4 -0
  44. data/app/controllers/decidim/static_map_controller.rb +2 -0
  45. data/app/controllers/decidim/timeouts_controller.rb +4 -0
  46. data/app/controllers/decidim/tos_controller.rb +2 -0
  47. data/app/controllers/decidim/upload_validations_controller.rb +4 -2
  48. data/app/controllers/decidim/user_activities_controller.rb +3 -0
  49. data/app/forms/decidim/upload_validation_form.rb +16 -10
  50. data/app/helpers/concerns/decidim/user_role_checker.rb +35 -10
  51. data/app/helpers/decidim/decidim_form_helper.rb +1 -1
  52. data/app/helpers/decidim/sanitize_helper.rb +11 -1
  53. data/app/jobs/decidim/application_job.rb +4 -0
  54. data/app/models/decidim/authorization.rb +24 -0
  55. data/app/models/decidim/oauth_application.rb +1 -0
  56. data/app/models/decidim/user.rb +1 -0
  57. data/app/models/decidim/user_base_entity.rb +1 -1
  58. data/app/packs/src/decidim/callout.js +10 -0
  59. data/app/packs/src/decidim/callout.test.js +32 -0
  60. data/app/packs/src/decidim/controllers/accordion/accordion.test.js +64 -0
  61. data/app/packs/src/decidim/controllers/accordion/controller.js +43 -0
  62. data/app/packs/src/decidim/controllers/autofocus/controller.js +30 -0
  63. data/app/packs/src/decidim/controllers/language_change/controller.js +21 -8
  64. data/app/packs/src/decidim/controllers/language_change/language_change.test.js +0 -1
  65. data/app/packs/src/decidim/controllers/mention/controller.js +32 -3
  66. data/app/packs/src/decidim/controllers/mention/input_mentions.test.js +8 -5
  67. data/app/packs/src/decidim/controllers/multiple_mentions/input_multiple_mentions.test.js +1 -1
  68. data/app/packs/src/decidim/datepicker/datepicker_functions.js +41 -25
  69. data/app/packs/src/decidim/datepicker/generate_datepicker.js +4 -4
  70. data/app/packs/src/decidim/datepicker/test/datepicker_functions_adjust_picker_position.test.js +64 -0
  71. data/app/packs/src/decidim/datepicker/test/time.test.js +39 -24
  72. data/app/packs/src/decidim/editor/common/suggestion.js +32 -22
  73. data/app/packs/src/decidim/editor/extensions/mention/index.js +14 -3
  74. data/app/packs/src/decidim/editor/index.js +4 -0
  75. data/app/packs/src/decidim/editor/test/extensions/mention.test.js +32 -9
  76. data/app/packs/src/decidim/refactor/implementation/autofocus.js +22 -0
  77. data/app/packs/src/decidim/refactor/implementation/autofocus.test.js +171 -0
  78. data/app/packs/src/decidim/utilities/dom.js +2 -2
  79. data/app/packs/stylesheets/decidim/_content_blocks.scss +4 -10
  80. data/app/packs/stylesheets/decidim/_conversations.scss +0 -8
  81. data/app/packs/stylesheets/decidim/_modal.scss +16 -8
  82. data/app/packs/stylesheets/decidim/_modal_share.scss +0 -22
  83. data/app/packs/stylesheets/decidim/_participatory_spaces.scss +11 -1
  84. data/app/packs/stylesheets/decidim/_rich_text.scss +12 -9
  85. data/app/packs/stylesheets/decidim/_typography.scss +5 -2
  86. data/app/packs/stylesheets/decidim/application.scss +1 -0
  87. data/app/packs/stylesheets/decidim/legacy/email.scss +0 -5
  88. data/app/permissions/decidim/permissions.rb +20 -0
  89. data/app/presenters/decidim/admin_log/component_presenter.rb +1 -1
  90. data/app/presenters/decidim/log/base_presenter.rb +10 -1
  91. data/app/presenters/decidim/log/user_presenter.rb +2 -2
  92. data/app/presenters/decidim/menu_item_presenter.rb +1 -5
  93. data/app/presenters/decidim/resource_presenter.rb +11 -0
  94. data/app/resolvers/decidim/meta_image_url_resolver.rb +7 -1
  95. data/app/serializers/decidim/exporters/open_data_blocked_user_serializer.rb +11 -2
  96. data/app/serializers/decidim/exporters/open_data_moderation_serializer.rb +25 -1
  97. data/app/services/decidim/svg_content_validator.rb +554 -0
  98. data/app/uploaders/decidim/application_uploader.rb +2 -2
  99. data/app/validators/uploader_image_content_validator.rb +276 -0
  100. data/app/views/decidim/application/_radio_accordion.html.erb +1 -5
  101. data/app/views/decidim/messaging/conversations/_add_conversation_users.html.erb +1 -1
  102. data/app/views/decidim/messaging/conversations/create.js.erb +1 -3
  103. data/app/views/decidim/shared/_login_modal.html.erb +12 -14
  104. data/app/views/layouts/decidim/_mailer_logo.html.erb +1 -1
  105. data/app/views/layouts/decidim/header/_menu_breadcrumb_desktop.html.erb +3 -2
  106. data/config/initializers/rack_attack.rb +10 -0
  107. data/config/locales/bg.yml +0 -1
  108. data/config/locales/ca-IT.yml +3 -1
  109. data/config/locales/ca.yml +10 -6
  110. data/config/locales/cs.yml +15 -1
  111. data/config/locales/de.yml +99 -3
  112. data/config/locales/en.yml +5 -1
  113. data/config/locales/es-MX.yml +5 -1
  114. data/config/locales/es-PY.yml +5 -1
  115. data/config/locales/es.yml +9 -5
  116. data/config/locales/eu.yml +161 -160
  117. data/config/locales/fi-plain.yml +3 -1
  118. data/config/locales/fi.yml +3 -1
  119. data/config/locales/fr-CA.yml +5 -1
  120. data/config/locales/fr.yml +5 -1
  121. data/config/locales/gl.yml +1485 -33
  122. data/config/locales/hu.yml +0 -1
  123. data/config/locales/ja.yml +3 -1
  124. data/config/locales/pl.yml +0 -1
  125. data/config/locales/pt-BR.yml +0 -1
  126. data/config/locales/ro-RO.yml +0 -1
  127. data/config/locales/sk.yml +2 -1
  128. data/config/locales/sv.yml +68 -12
  129. data/config/locales/tr-TR.yml +0 -2
  130. data/config/routes.rb +11 -2
  131. data/db/migrate/20260909000000_add_failed_attempts_and_locked_at_to_decidim_authorizations.rb +8 -0
  132. data/decidim-core.gemspec +1 -1
  133. data/lib/decidim/api/functions/user_entity_list.rb +1 -0
  134. data/lib/decidim/api/types/user_moderation_type.rb +2 -2
  135. data/lib/decidim/content_parsers/user_parser.rb +1 -1
  136. data/lib/decidim/content_parsers.rb +0 -1
  137. data/lib/decidim/content_renderers/mention_resource_renderer.rb +18 -4
  138. data/lib/decidim/content_renderers/user_renderer.rb +15 -4
  139. data/lib/decidim/core/test/shared_examples/participatory_space_members_page_examples.rb +77 -0
  140. data/lib/decidim/core/test/shared_examples/participatory_space_members_shared_examples.rb +58 -0
  141. data/lib/decidim/core/test/shared_examples/versions_controller_examples.rb +23 -1
  142. data/lib/decidim/core/version.rb +1 -1
  143. data/lib/decidim/core.rb +9 -0
  144. data/lib/decidim/form_builder.rb +9 -1
  145. data/lib/decidim/has_upload_validations.rb +1 -0
  146. data/lib/decidim/legacy_form_builder.rb +4 -0
  147. metadata +12 -7
  148. data/lib/decidim/content_parsers/inline_images_parser.rb +0 -68
  149. data/lib/tasks/upgrade/decidim_active_storage_migration_tasks.rake +0 -79
@@ -16,10 +16,19 @@ module Decidim
16
16
  about: resource.user.about,
17
17
  reasons: resource.reports.map(&:reason),
18
18
  details: resource.reports.map(&:details),
19
- block_reasons: resource.blocking.justification,
20
- blocking_user: resource.blocking.blocking_user.presenter.name
19
+ block_reasons: blocking&.justification,
20
+ blocking_user: blocking_user&.presenter&.name
21
21
  }
22
22
  end
23
+
24
+ private
25
+
26
+ # The collection filters on `decidim_users.blocked_at`, but this
27
+ # association is resolved through `decidim_users.block_id`. The two can
28
+ # disagree, in which case there is no block to read the details from.
29
+ def blocking = resource.blocking
30
+
31
+ def blocking_user = blocking&.blocking_user
23
32
  end
24
33
  end
25
34
  end
@@ -14,7 +14,7 @@ module Decidim
14
14
  id: resource.id,
15
15
  hidden_at: resource.hidden_at,
16
16
  report_count: resource.report_count,
17
- reported_url: resource.reportable.reported_content_url,
17
+ reported_url:,
18
18
  reportable_type: resource.decidim_reportable_type,
19
19
  reportable_id: resource.decidim_reportable_id,
20
20
  reported_content: resource.reported_content,
@@ -25,6 +25,30 @@ module Decidim
25
25
  }
26
26
  }
27
27
  end
28
+
29
+ private
30
+
31
+ # `Decidim::Moderation#reportable` is polymorphic, so the database cannot
32
+ # enforce the reference, and the moderation row outlives the resource it
33
+ # points at. Both of the cases below leave the moderation in the export
34
+ # collection with no URL to build.
35
+ def reported_url
36
+ reportable = resource.reportable
37
+
38
+ # The resource is gone. Reportables such as proposals and meetings are
39
+ # soft-deletable while `belongs_to :reportable` does not use
40
+ # `with_deleted`, so moving one to the trash is enough for this.
41
+ return if reportable.nil?
42
+
43
+ # The component is gone but the resource is not: trashing a component
44
+ # does not trash its contents. `ResourceLocatorPresenter#route_proxy`
45
+ # falls back to the resource itself when `component` is nil, and
46
+ # `EngineRouter.main_proxy` then calls `mounted_engine` on it, which
47
+ # only components and participatory spaces respond to.
48
+ return if reportable.is_a?(Decidim::HasComponent) && reportable.component.nil?
49
+
50
+ reportable.reported_content_url
51
+ end
28
52
  end
29
53
  end
30
54
  end
@@ -0,0 +1,554 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Decidim
6
+ # Validates the contents of SVG documents.
7
+ #
8
+ # SVG documents are XML-based and can contain scripts and other active
9
+ # content, which is executed when the document is rendered by the browser.
10
+ # Because of that, the contents of files detected as SVG need an additional
11
+ # validation, so that documents which could execute scripts or read external
12
+ # resources are rejected instead of being stored as they are.
13
+ #
14
+ # The document must be parseable as XML with an "svg" root element and must
15
+ # not declare entities nor contain any other content which could execute
16
+ # scripts or read external resources when the document is rendered.
17
+ #
18
+ # The references which load resources into the document (the "href" of
19
+ # "image", "use", "feImage" and "cursor", the URL references of the
20
+ # presentation attributes, the style sheets and the "xml-stylesheet"
21
+ # instruction) are restricted to the document itself and to inline data, so
22
+ # that documents cannot make the browser of the reader fetch third-party
23
+ # resources. The contents of the inline data references declaring
24
+ # an SVG document are validated as nested documents, as they are parsed when
25
+ # the reference is rendered. Links ("a") are not restricted, as they do not
26
+ # load any resource when the document is rendered; they can still reference
27
+ # any host, so they are a phishing surface which is accepted on purpose.
28
+ #
29
+ # This is used by UploaderImageContentValidator, which delegates the SVG
30
+ # specific validation to this class, so callers of that validator only need
31
+ # to call a single validator for all the image formats.
32
+ class SvgContentValidator
33
+ # The elements which are not allowed in SVG documents, because they either
34
+ # execute scripts themselves (e.g. "script" and the SVG Tiny "handler") or
35
+ # allow embedding foreign content which can execute them (e.g.
36
+ # "foreignObject"). The names are kept in lowercase, as the names found in
37
+ # the documents are compared case insensitively.
38
+ SVG_FORBIDDEN_ELEMENTS = %w(script handler foreignobject iframe embed object applet).freeze
39
+
40
+ # The names of the attributes which are not allowed in SVG documents, as
41
+ # event handlers execute scripts when the document is rendered.
42
+ SVG_EVENT_HANDLER_NAME = /\Aon[a-z]/i
43
+
44
+ # The URI prefixes which are not allowed in the attribute values of SVG
45
+ # documents, as they execute scripts.
46
+ SVG_FORBIDDEN_URI_PREFIXES = %w(javascript: livescript: vbscript: data:text/html).freeze
47
+
48
+ # The attributes of the SVG animation elements which define the attribute
49
+ # modified by the animation. Through them an animation can set an event
50
+ # handler or another forbidden value at runtime, so the values of these
51
+ # attributes are validated as element/attribute names. The names are kept
52
+ # in lowercase and compared case insensitively, as the HTML parser resolves
53
+ # the names written in lowercase (e.g. "attributename") to the camel case
54
+ # ones when the document is parsed as HTML.
55
+ SVG_ANIMATION_TARGET_NAMES = %w(attribute attributename attributenames).freeze
56
+
57
+ # The elements which load the resource referenced by their "href" (or
58
+ # legacy "xlink:href") attribute into the document when it is rendered.
59
+ # The references of these elements are restricted to the document itself
60
+ # and to inline data, as absolute references make the browser of the
61
+ # reader fetch resources from third-party hosts, which can be used for
62
+ # tracking or for probing internal networks. Links ("a") are not
63
+ # included, as they do not load any resource; they can still reference
64
+ # any host, so they are a phishing surface which is accepted on purpose.
65
+ SVG_RESOURCE_ELEMENTS = %w(image use feimage cursor).freeze
66
+
67
+ # The presentation attributes whose value defines a reference resolved when
68
+ # the document is rendered. The image references (e.g. "cursor" and "mask")
69
+ # are fetched as resources, and the references to paint servers, filters,
70
+ # clipping paths and markers can also resolve to an element of another
71
+ # document, which is fetched unless the document is processed in a secure
72
+ # mode. Their values are validated as style sheet content, as they are
73
+ # parsed as the CSS properties they define.
74
+ SVG_URL_PRESENTATION_ATTRIBUTES = %w(
75
+ cursor mask fill stroke filter clip-path marker marker-start marker-mid marker-end
76
+ ).freeze
77
+
78
+ # The name of the attribute which references the resource loaded by an
79
+ # element, either "href" or the legacy "xlink:href", which is also
80
+ # matched with an undeclared prefix, as browsers resolve it as the XLink
81
+ # reference.
82
+ SVG_REFERENCE_NAME = /\A(?:[\w.-]+:)?href\z/i
83
+
84
+ # The references which load a resource from another host: URIs with a
85
+ # scheme (e.g. "https:") and references resolving to an authority, which
86
+ # browsers also detect from two separators at the beginning of the
87
+ # reference (e.g. "//example.org", "/\example.org" or "\\example.org"), as
88
+ # backslashes are treated as separators of the authority for the schemes of
89
+ # the web. A single backslash only starts a path, so it is not matched.
90
+ # Fragment ("#") and relative references are resolved against the document
91
+ # itself, and "data:" references are inline, so they are accepted (see
92
+ # #forbidden_reference_value?).
93
+ SVG_EXTERNAL_REFERENCE = %r{\A(?!data:)(?:[a-z][a-z0-9+.-]*:|[\\/]{2})}i
94
+
95
+ # The inline data references whose contents are parsed when the document is
96
+ # rendered. The SVG ones are validated as nested documents, as the rules
97
+ # above cannot be applied to a payload which is stored encoded (e.g. in
98
+ # base64), and the other ones are accepted when they do not declare a
99
+ # forbidden content type (see SVG_FORBIDDEN_URI_PREFIXES). The parameters of
100
+ # the MIME type (e.g. "image/svg+xml;charset=utf-8") are matched as well, as
101
+ # browsers still parse the payload as a document, and the "base64" one can
102
+ # appear in any position of the list.
103
+ SVG_DATA_URI = %r{\Adata:(?<mime>image/svg\+xml)(?<parameters>(?:;[^;,]*)*),(?<payload>.*)\z}im
104
+
105
+ # The CSS functions which load the resources they reference when the style
106
+ # sheet is rendered. The strings inside them are interpreted as URLs, so the
107
+ # arguments of "image-set()" define a reference as the arguments of "url()"
108
+ # do, and the vendor prefixed spelling is matched as well.
109
+ CSS_URL_FUNCTIONS = %w(url image-set -webkit-image-set).freeze
110
+
111
+ # The URL references of a style sheet, captured after the comments and the
112
+ # strings which do not define a URL are removed, so that each of them can
113
+ # be validated as a reference. Only the strings of the functions which load
114
+ # their arguments survive the scrubbing, so the remaining strings are URL
115
+ # values, and the unquoted value of a "url()" function is matched as well.
116
+ SVG_CSS_URL_VALUE = /["']([^"')]+)["']?|(?:url|image-set)\(\s*([^"')]+)/im
117
+
118
+ # The maximum depth of the documents nested inside inline data references,
119
+ # which are validated with the same rules as the document itself.
120
+ MAX_NESTED_DEPTH = 5
121
+
122
+ # The maximum size of the contents to validate, as a safety net for the
123
+ # documents which reach this validator without a size limit.
124
+ MAX_CONTENT_SIZE = 10 * 1024 * 1024
125
+
126
+ # The elements which modify the attributes of another element at runtime.
127
+ # When they target a reference attribute of an element which loads the
128
+ # referenced resource, the values assigned to it are validated as
129
+ # references, as the resource is loaded when the animation reaches it. When
130
+ # they target the "style" attribute or one of the presentation attributes
131
+ # which resolve a URL reference, the values are validated as style sheet
132
+ # content, as the URL references they declare are loaded when the animation
133
+ # reaches them.
134
+ SVG_ANIMATION_ELEMENTS = %w(animate set).freeze
135
+ SVG_ANIMATION_VALUE_NAMES = %w(values from to by).freeze
136
+
137
+ # The CSS at-rules which load external resources when the style sheets of
138
+ # an SVG document are rendered. Imports always fetch a resource, so
139
+ # documents containing them are rejected. The URL references are validated
140
+ # separately as references (see SVG_CSS_URL_VALUE), as the same rules apply
141
+ # to them: references with a scheme or an authority fetch a resource from
142
+ # another host, fragment and relative references are resolved against the
143
+ # document itself, and "data:" references are inline but can contain a
144
+ # nested document which is parsed when rendered.
145
+ SVG_CSS_IMPORT = /@import/i
146
+
147
+ # The escape sequences of CSS, which consist of a backslash followed by up
148
+ # to six hexadecimal digits, with an optional whitespace terminating the
149
+ # sequence, or by any other character.
150
+ CSS_ESCAPE = /\\(?:(?<hex>\h{1,6})[ \t\n\r\f]?|(?<char>.))/m
151
+
152
+ # Validates the contents of an SVG document. Returns :valid when the
153
+ # document can be stored, :invalid when the contents are not a well formed
154
+ # SVG document, and :unsafe when the document contains content which could
155
+ # execute scripts or read external resources when it is rendered.
156
+ def self.validate(content)
157
+ new(content).validate
158
+ end
159
+
160
+ def initialize(content, depth = 0)
161
+ @content = content
162
+ @depth = depth
163
+ end
164
+
165
+ def validate
166
+ return :invalid if content.nil?
167
+ # The contents which are not valid in their encoding cannot be checked by
168
+ # the expressions below, so they are rejected as unsafe.
169
+ return :unsafe unless content.valid_encoding?
170
+ return :invalid if content.blank?
171
+ return :invalid if content.bytesize > MAX_CONTENT_SIZE
172
+
173
+ document = parse
174
+ return :invalid if document.nil?
175
+ return :unsafe if entity_declaration?(document)
176
+ return :unsafe if forbidden_content?(document)
177
+
178
+ :valid
179
+ end
180
+
181
+ private
182
+
183
+ attr_reader :content, :depth
184
+
185
+ # Parses the SVG document in strict mode, so that malformed documents are
186
+ # rejected instead of being recovered with a partial tree, and without
187
+ # fetching any external resource (e.g. an external DTD) or substituting
188
+ # entities, so that a malicious document cannot make the server read external
189
+ # resources or expand entities while it is validated. Returns nil when the
190
+ # contents are not a well formed XML document with an "svg" root element.
191
+ def parse
192
+ document = Nokogiri::XML.parse(content) { |options| options.strict.nonet }
193
+ return nil if document.root.nil? || document.root.name != "svg"
194
+
195
+ document
196
+ rescue Nokogiri::XML::SyntaxError
197
+ nil
198
+ end
199
+
200
+ def forbidden_content?(document)
201
+ # The "xml-stylesheet" processing instruction loads an external style
202
+ # sheet when the document is rendered, so documents declaring one are
203
+ # rejected.
204
+ return true if document.xpath("//processing-instruction('xml-stylesheet')").any?
205
+
206
+ document.xpath("//*").any? { |node| forbidden_node?(node) }
207
+ end
208
+
209
+ def forbidden_node?(node)
210
+ return true if SVG_FORBIDDEN_ELEMENTS.include?(node.name.downcase)
211
+ return true if node.attribute_nodes.any? { |attribute| forbidden_attribute?(node, attribute) }
212
+ return true if forbidden_animation_reference?(node)
213
+
214
+ node.name.downcase == "style" && forbidden_css_content?(node.content.to_s)
215
+ end
216
+
217
+ def forbidden_attribute?(node, attribute)
218
+ return true if SVG_EVENT_HANDLER_NAME.match?(attribute.name)
219
+
220
+ return true if forbidden_uri_value?(attribute.value.to_s)
221
+ return true if css_content_name?(attribute.name) && forbidden_css_content?(attribute.value.to_s)
222
+ return true if animation_target_attribute?(attribute) && forbidden_name?(attribute.value.to_s)
223
+
224
+ forbidden_resource_reference?(node, attribute)
225
+ end
226
+
227
+ # Whether the name is parsed as the content of a style sheet, either because
228
+ # it declares the declarations of an element or because it is a presentation
229
+ # attribute whose CSS property resolves a URL reference (see
230
+ # SVG_URL_PRESENTATION_ATTRIBUTES).
231
+ def css_content_name?(name)
232
+ normalized = name.downcase
233
+ normalized == "style" || SVG_URL_PRESENTATION_ATTRIBUTES.include?(normalized)
234
+ end
235
+
236
+ # Whether the attribute references an external resource in an element
237
+ # which loads it when the document is rendered (e.g. the "href" of an
238
+ # "image").
239
+ def forbidden_resource_reference?(node, attribute)
240
+ return false unless resource_element?(node)
241
+ return false unless SVG_REFERENCE_NAME.match?(attribute.name)
242
+
243
+ forbidden_reference_value?(attribute.value.to_s)
244
+ end
245
+
246
+ # Whether an animation assigns an external resource to a reference
247
+ # attribute (e.g. "href") of an element which loads it, as the resource
248
+ # is loaded when the animation reaches the value. References which do not
249
+ # load a resource (e.g. the links of an "a") are not restricted, as they
250
+ # are never fetched when the document is rendered. The values assigned to
251
+ # the "style" attribute and to the presentation attributes which resolve a
252
+ # URL reference are validated as style sheet content, as the references they
253
+ # declare are loaded when the animation reaches them.
254
+ def forbidden_animation_reference?(node)
255
+ return false unless SVG_ANIMATION_ELEMENTS.include?(node.name.downcase)
256
+
257
+ target = animation_target(node)
258
+ values = animation_values(node)
259
+ return false if target.nil? || values.empty?
260
+
261
+ return true if css_content_name?(target) && forbidden_css_values?(values)
262
+ return false unless SVG_REFERENCE_NAME.match?(target) && animates_resource_element?(node)
263
+
264
+ forbidden_reference_values?(values)
265
+ end
266
+
267
+ def forbidden_css_values?(values)
268
+ values.any? { |value| forbidden_css_content?(value) }
269
+ end
270
+
271
+ def forbidden_reference_values?(values)
272
+ values.any? { |value| forbidden_reference_value?(value) }
273
+ end
274
+
275
+ # The attribute modified by an animation, normalized as the HTML parser
276
+ # resolves it when the document is parsed as HTML.
277
+ def animation_target(node)
278
+ attribute = node.attribute_nodes.find { |a| animation_target_attribute?(a) }
279
+ attribute&.value&.downcase
280
+ end
281
+
282
+ def animation_target_attribute?(attribute)
283
+ SVG_ANIMATION_TARGET_NAMES.any? { |name| attribute.name.casecmp?(name) }
284
+ end
285
+
286
+ # The values assigned by an animation, which are accepted as lists
287
+ # separated by semicolons.
288
+ def animation_values(node)
289
+ SVG_ANIMATION_VALUE_NAMES.flat_map { |name| node[name].to_s.split(";") }
290
+ end
291
+
292
+ # Whether the animation modifies the reference of an element which loads
293
+ # the referenced resource when the document is rendered. The animation
294
+ # modifies its parent element, unless it references another one with its
295
+ # own reference attribute (e.g. "xlink:href"), which browsers resolve to
296
+ # the element with that identifier. References which do not resolve to an
297
+ # element of the document are also considered as loading a resource, so
298
+ # that documents which could hide the modified element are rejected.
299
+ def animates_resource_element?(node)
300
+ references = node.attribute_nodes.select { |attribute| SVG_REFERENCE_NAME.match?(attribute.name) }
301
+ return resource_element?(node.parent) if references.empty?
302
+
303
+ references.any? do |attribute|
304
+ targets = referenced_elements(node.document, attribute.value.to_s)
305
+ targets.empty? || targets.any? { |element| resource_element?(element) }
306
+ end
307
+ end
308
+
309
+ # The elements of the document referenced by a target reference of an
310
+ # animation element, which browsers resolve to the element with the
311
+ # identifier of the fragment, after decoding its percent-encoded characters.
312
+ # The decoded identifier is encoded as UTF-8, as the identifiers of the
313
+ # document are read with that encoding. References to other documents or to
314
+ # missing identifiers do not resolve to any element.
315
+ def referenced_elements(document, reference)
316
+ id = reference[/\A#(.*)\z/m, 1]
317
+ return [] if id.nil?
318
+
319
+ elements_by_id(document).fetch(percent_decode(id).force_encoding(Encoding::UTF_8), [])
320
+ end
321
+
322
+ # The elements of the document which declare an identifier, grouped by it.
323
+ # The index is built once per document and reused for every reference, as
324
+ # searching the whole document for each of the animations would take a
325
+ # long time for documents with many elements and animations.
326
+ def elements_by_id(document)
327
+ @elements_by_id ||= {}
328
+ @elements_by_id[document] ||= document.xpath("//*[@id]").group_by { |element| element["id"] }
329
+ end
330
+
331
+ # Whether the element loads the resource referenced by its reference
332
+ # attribute when the document is rendered.
333
+ def resource_element?(element)
334
+ SVG_RESOURCE_ELEMENTS.include?(element.name.downcase)
335
+ end
336
+
337
+ # Whether the reference loads a resource from another host or contains a
338
+ # nested document which could execute scripts or read external resources
339
+ # when it is rendered. The whitespace and control characters which can be
340
+ # used to obfuscate the scheme are removed before checking.
341
+ def forbidden_reference_value?(value)
342
+ return true if SVG_EXTERNAL_REFERENCE.match?(normalize_uri_value(value))
343
+ return true if forbidden_uri_value?(value)
344
+
345
+ nested_document_unsafe?(value)
346
+ end
347
+
348
+ # Whether the contents of an inline data reference declaring an SVG
349
+ # document are unsafe, as they are parsed as a document of their own when
350
+ # the reference is rendered. The nested documents are validated with the
351
+ # same rules, up to a maximum depth, so that a document referencing itself
352
+ # cannot be used to recurse indefinitely. A nested document which cannot be
353
+ # validated because the maximum depth is reached is rejected, as its contents
354
+ # are still parsed when the reference is rendered.
355
+ def nested_document_unsafe?(value)
356
+ match = SVG_DATA_URI.match(value.strip)
357
+ return false if match.nil?
358
+ return true if depth >= MAX_NESTED_DEPTH
359
+
360
+ payload = match[:parameters].downcase.include?(";base64") ? decode_base64(match[:payload]) : percent_decode(match[:payload])
361
+ Decidim::SvgContentValidator.new(payload, depth + 1).validate == :unsafe
362
+ end
363
+
364
+ def decode_base64(value)
365
+ value.unpack1("m")
366
+ end
367
+
368
+ # Decodes the percent-encoded characters of a data URI, which are otherwise
369
+ # invisible to the checks applied to the contents of the document. The
370
+ # substitutions are applied to the bytes of the value, as the decoded
371
+ # characters can be part of a multi-byte sequence which is not valid in the
372
+ # encoding of the value. The result is kept as bytes, so that the encoding
373
+ # declared by a nested document is still read by the parser (see #parse);
374
+ # the callers which compare the decoded value with the contents of the
375
+ # document encode it as UTF-8.
376
+ def percent_decode(value)
377
+ value.dup.force_encoding(Encoding::BINARY).gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1).hex].pack("C") }
378
+ end
379
+
380
+ # Whether the value is a script URI or contains one. The animation elements
381
+ # accept lists of values separated by semicolons (e.g. in the "values"
382
+ # attribute), which are assigned to the target attribute one after another
383
+ # at runtime, so each value of the list is checked separately.
384
+ def forbidden_uri_value?(value)
385
+ value.split(";").any? do |uri|
386
+ normalized = normalize_uri_value(uri)
387
+ SVG_FORBIDDEN_URI_PREFIXES.any? { |prefix| normalized.start_with?(prefix) }
388
+ end
389
+ end
390
+
391
+ # Whether the value targets an event handler or a forbidden element, as
392
+ # used by the animation elements to modify attributes at runtime. Lists of
393
+ # names are also accepted by some animation attributes, so each name of the
394
+ # list is checked separately.
395
+ def forbidden_name?(name)
396
+ name.split(";").any? do |part|
397
+ normalized = part.strip.downcase
398
+ SVG_EVENT_HANDLER_NAME.match?(normalized) || SVG_FORBIDDEN_ELEMENTS.include?(normalized)
399
+ end
400
+ end
401
+
402
+ # Whether the document type definition declares an entity. Entities can be
403
+ # used to read external resources (XXE) or to expand content exponentially
404
+ # (billion laughs), so documents declaring one are rejected. Only the
405
+ # declarations of the internal subset are considered, as the external ones
406
+ # are never fetched (see #parse). Text which only looks like a declaration
407
+ # outside of the document type definition (e.g. inside a comment, a CDATA
408
+ # section or a processing instruction) is not a declaration, so it does not
409
+ # reject a legitimate document.
410
+ def entity_declaration?(document)
411
+ internal_subset = document.internal_subset
412
+ return false if internal_subset.nil?
413
+
414
+ internal_subset.children.any? { |node| node.type == Nokogiri::XML::Node::ENTITY_DECL }
415
+ end
416
+
417
+ # Whether the CSS of a "style" element or attribute loads external
418
+ # resources when the document is rendered. The comments and the strings
419
+ # which do not define a URL are removed, and the escape sequences decoded,
420
+ # before checking, as they can otherwise hide the references (e.g.
421
+ # "u\rl(https://example.org)") or be mistaken for them (e.g. 'content:
422
+ # "See @import instructions"').
423
+ def forbidden_css_content?(css)
424
+ normalized = decode_css_escapes(scrub_css(css))
425
+ return true if SVG_CSS_IMPORT.match?(normalized)
426
+
427
+ normalized.scan(SVG_CSS_URL_VALUE).any? { |groups| forbidden_reference_value?(groups.compact.first) }
428
+ end
429
+
430
+ # Removes the comments and the strings of a style sheet, which are not
431
+ # interpreted as directives when the style sheet is rendered, so that text
432
+ # which only looks like a reference (e.g. 'content: "See @import"') is not
433
+ # mistaken for one. The strings which define the URL of a function which
434
+ # loads its arguments are kept, as those URLs are loaded. Comments and
435
+ # strings are detected following the tokenization rules of CSS, in which
436
+ # comments are not recognized inside strings and escape sequences hide the
437
+ # character following them, so that neither can be used to hide a reference
438
+ # (e.g. 'content: "/*"; background: url(https://example.org)'). The reference
439
+ # of a function is kept after a comment as well, as some engines tokenize the
440
+ # comment inside the function instead of reading it as part of the URL.
441
+ def scrub_css(css)
442
+ scanner = StringScanner.new(css)
443
+ output = +""
444
+ url_functions = []
445
+ until scanner.eos?
446
+ if scanner.scan(%r{/\*})
447
+ scan_css_comment(scanner)
448
+ elsif (quote = scanner.scan(/["']/))
449
+ # The string token is always consumed, as its content is not
450
+ # interpreted as CSS. Only the strings of the functions which load
451
+ # their arguments define a loaded URL.
452
+ string = scan_css_string(scanner, quote)
453
+ output << string if url_functions.any?
454
+ elsif (ident = scan_css_ident(scanner))
455
+ output << ident
456
+ if scanner.match?(/\(/)
457
+ url_functions.push(css_url_function?(ident))
458
+ output << scanner.getch
459
+ end
460
+ else
461
+ char = scanner.getch
462
+ output << char
463
+ # The arguments of these functions are kept until the function ends,
464
+ # as "image-set()" can declare several URLs. The opening parentheses
465
+ # which do not follow an identifier are tracked as well, so that their
466
+ # closing ones do not end an enclosing function and drop its arguments.
467
+ url_functions.push(false) if char == "("
468
+ url_functions.pop if char == ")"
469
+ end
470
+ end
471
+ output
472
+ end
473
+
474
+ # Consumes the comment token of CSS which starts at the current position.
475
+ # Unterminated comments extend to the end of the style sheet.
476
+ def scan_css_comment(scanner)
477
+ scanner.scan_until(%r{\*/}) || (scanner.pos = scanner.string.length)
478
+ end
479
+
480
+ # Consumes the string token of CSS which starts at the given quote, and
481
+ # returns it with its quotes. Escape sequences hide the following
482
+ # character, and an unescaped newline or the end of the input end the
483
+ # token, as in the tokenization rules of CSS.
484
+ def scan_css_string(scanner, quote)
485
+ token = quote.dup
486
+ until scanner.eos?
487
+ escape = scanner.scan(CSS_ESCAPE)
488
+ if escape
489
+ token << escape
490
+ elsif scanner.match?(quote)
491
+ token << scanner.getch
492
+ break
493
+ elsif scanner.match?(/[\n\r\f]/)
494
+ break
495
+ else
496
+ token << scanner.getch
497
+ end
498
+ end
499
+ token
500
+ end
501
+
502
+ # Consumes the identifier token of CSS which starts at the current
503
+ # position, including its escape sequences, and returns it as it is
504
+ # written. Returns nil when no identifier starts at the current position.
505
+ # An identifier starts with a letter, an underscore, a non-ASCII character,
506
+ # an escape sequence, or a hyphen followed by one of those, and continues
507
+ # with any of those or a digit: digits are only accepted after the first
508
+ # character, as "5url(" is a number followed by a function while "url5(" is
509
+ # another function.
510
+ def scan_css_ident(scanner)
511
+ prefix = scanner.scan(/-?(?:[a-zA-Z_]|[^\x00-\x7f])/) || (scanner.match?(CSS_ESCAPE) && "")
512
+ return nil if prefix.nil?
513
+
514
+ ident = prefix.dup
515
+ loop do
516
+ char = scanner.scan(CSS_ESCAPE) || scanner.scan(/[-\w]|[^\x00-\x7f]/)
517
+ break if char.nil?
518
+
519
+ ident << char
520
+ end
521
+ ident
522
+ end
523
+
524
+ # Whether the identifier is the name of a function which loads its arguments
525
+ # as resources. The name is matched after decoding its escape sequences, as
526
+ # they are processed before the name is interpreted, so that functions
527
+ # written with escapes (e.g. "u\72l(...)") are also recognized.
528
+ def css_url_function?(ident)
529
+ CSS_URL_FUNCTIONS.any? { |name| decode_css_escapes(ident).casecmp?(name) }
530
+ end
531
+
532
+ # Decodes the escape sequences of CSS. Invalid codepoints are replaced with
533
+ # the replacement character.
534
+ def decode_css_escapes(css)
535
+ css.gsub(CSS_ESCAPE) do
536
+ digits = Regexp.last_match[:hex]
537
+ next Regexp.last_match[:char] if digits.nil?
538
+
539
+ codepoint = digits.to_i(16)
540
+ if codepoint.zero? || codepoint > 0x10FFFF || codepoint.between?(0xD800, 0xDFFF)
541
+ "\uFFFD"
542
+ else
543
+ [codepoint].pack("U")
544
+ end
545
+ end
546
+ end
547
+
548
+ # Removes the whitespace and control characters which can be used to
549
+ # obfuscate URI schemes (e.g. "java\tscript:") and normalizes the casing.
550
+ def normalize_uri_value(value)
551
+ value.gsub(/[[:space:][:cntrl:]]/, "").downcase
552
+ end
553
+ end
554
+ end
@@ -40,7 +40,7 @@ module Decidim
40
40
 
41
41
  def url(options = {})
42
42
  representable = model.send(mounted_as)
43
- return super unless representable.is_a? ActiveStorage::Attached
43
+ return unless representable.is_a? ActiveStorage::Attached
44
44
 
45
45
  variant_url(options.delete(:variant), **options)
46
46
  end
@@ -54,7 +54,7 @@ module Decidim
54
54
 
55
55
  def path(options = {})
56
56
  representable = model.send(mounted_as)
57
- return super() unless representable.is_a? ActiveStorage::Attached
57
+ return unless representable.is_a? ActiveStorage::Attached
58
58
 
59
59
  variant_path(options.delete(:variant), **options)
60
60
  end