universal_renderer 0.5.2 → 0.7.0

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.
@@ -1,61 +1,150 @@
1
+ require "active_support/core_ext/object/blank"
1
2
  require "loofah"
2
3
 
3
4
  module UniversalRenderer
4
5
  module SSR
6
+ # Removes executable content from HTML returned by the SSR service while
7
+ # preserving the elements and data attributes needed to hydrate an app.
8
+ #
9
+ # A blocklist over the whole HTML grammar cannot be a boundary against
10
+ # attacker-controlled markup, because the sanitizer and the browser have to
11
+ # agree on how the document parses. Treat it as defense in depth over HTML
12
+ # your own renderer produced.
5
13
  class Scrubber < ::Loofah::Scrubber
14
+ # Elements that change how the *rest* of the markup is tokenized, so the
15
+ # tree this scrubber inspects is not the tree the browser builds.
16
+ #
17
+ # `<noscript>` is raw text with scripting enabled (the browser) and markup
18
+ # without it (the sanitizer), so `<noscript><p title="</noscript><img src=x
19
+ # onerror=...>">` reaches the browser as a live element. The MathML three
20
+ # flip the parser between foreign content and HTML integration points for
21
+ # the same effect. None belong in a server render.
22
+ PARSER_CONTEXT_ELEMENTS = %w[
23
+ noscript mglyph malignmark annotation-xml
24
+ ].freeze
25
+
26
+ BLOCKED_ELEMENTS = (
27
+ %w[
28
+ base embed frame frameset iframe object script
29
+ animate animatemotion animatetransform set
30
+ ] + PARSER_CONTEXT_ELEMENTS
31
+ ).freeze
32
+ # Non-executable data-script MIME types. HTML treats a script whose type is
33
+ # neither a JavaScript MIME type nor one of the special types (`module`,
34
+ # `importmap`, `speculationrules`) as an inert data block.
35
+ #
36
+ # The exception exists for JSON-LD, which the render emits and which would
37
+ # otherwise be sanitized away along with the SEO that motivates SSR.
38
+ ALLOWED_SCRIPT_TYPES = %w[application/json application/ld+json].freeze
39
+ URI_ATTRIBUTES = %w[
40
+ action background cite codebase data formaction href longdesc poster src
41
+ srcset xlink:href
42
+ ].freeze
43
+ ALLOWED_PROTOCOLS = %w[http https mailto tel].freeze
44
+ DANGEROUS_PROTOCOL = /\A(?:javascript|vbscript):/i
45
+ DANGEROUS_DATA = %r{\Adata:(?:text/html|application/xhtml\+xml|image/svg\+xml)}i
46
+ SAFE_DATA_IMAGE = %r{\Adata:image/(?:avif|gif|jpeg|png|webp);base64,}i
47
+ # An SVG referenced by <img> renders in a restricted mode: no scripts, no
48
+ # external references. Bundlers routinely inline icons as data URIs, so the
49
+ # render would otherwise lose them. `<source>` inside `<picture>` feeds the
50
+ # same mode; inside `<video>`/`<audio>` it does not.
51
+ INLINE_SVG_DATA = %r{\Adata:image/svg\+xml[,;]}i
52
+ IMAGE_SOURCE_ATTRIBUTES = %w[src srcset].freeze
53
+
6
54
  def initialize
7
55
  super
8
56
  @direction = :top_down
9
57
  end
10
58
 
11
59
  def scrub(node)
12
- # Primary actions: stop if script, continue if a passthrough tag, otherwise clean attributes.
13
- return Loofah::Scrubber::STOP if handle_script_node(node) # Checks for <script> and removes it
60
+ return Loofah::Scrubber::CONTINUE unless node.element?
14
61
 
15
- # Allows <link rel="stylesheet">, <style>, <meta> to pass through this scrubber.
16
- return Loofah::Scrubber::CONTINUE if passthrough_node?(node)
62
+ if blocked_element?(node) || refreshing_meta?(node)
63
+ node.remove
64
+ return Loofah::Scrubber::STOP
65
+ end
17
66
 
18
- # For all other nodes, clean potentially harmful attributes.
19
67
  clean_attributes(node)
20
- # Default Loofah behavior (CONTINUE for children) applies if not returned earlier.
68
+ Loofah::Scrubber::CONTINUE
21
69
  end
22
70
 
23
71
  private
24
72
 
25
- # Handles <script> tags: removes them and returns true if a script node was processed.
26
- def handle_script_node(node)
27
- return false unless node.name == "script"
73
+ def blocked_element?(node)
74
+ return false if node.name.downcase == "script" && data_script?(node)
28
75
 
29
- node.remove
30
- true # Indicates the node was a script and has been handled.
76
+ BLOCKED_ELEMENTS.include?(node.name.downcase)
31
77
  end
32
78
 
33
- # Checks if the node is a type that should bypass detailed attribute scrubbing.
34
- def passthrough_node?(node)
35
- (node.name == "link" && node["rel"]&.to_s&.downcase == "stylesheet") ||
36
- %w[style meta].include?(node.name)
79
+ # Scripts with no explicit type are JavaScript, so they stay blocked.
80
+ def data_script?(node)
81
+ type = node["type"].to_s.downcase.strip
82
+ return false if type.blank?
83
+
84
+ ALLOWED_SCRIPT_TYPES.include?(type.split(";", 2).first.strip)
37
85
  end
38
86
 
39
- # Orchestrates the cleaning of attributes for a given node.
87
+ def refreshing_meta?(node)
88
+ node.name.downcase == "meta" &&
89
+ node["http-equiv"]&.strip&.casecmp?("refresh")
90
+ end
91
+
92
+ # Attribute *nodes*, not the `attributes` hash: that hash is keyed by local
93
+ # name, so under the HTML5 parser `xlink:href` arrives as `"href"` and
94
+ # `node["href"]` returns nil. An empty value read as safe, which left
95
+ # `<svg><a xlink:href="javascript:...">` intact.
40
96
  def clean_attributes(node)
41
- remove_javascript_href(node)
42
- remove_event_handlers(node)
97
+ node.attribute_nodes.each do |attribute|
98
+ normalized_name = qualified_name(attribute)
99
+ remove_attribute =
100
+ normalized_name.start_with?("on") ||
101
+ normalized_name == "srcdoc" ||
102
+ (URI_ATTRIBUTES.include?(normalized_name) &&
103
+ unsafe_uri?(attribute.value.to_s, normalized_name, node))
104
+
105
+ attribute.remove if remove_attribute
106
+ end
43
107
  end
44
108
 
45
- # Removes "javascript:" hrefs from <a> tags.
46
- def remove_javascript_href(node)
47
- return unless node.name == "a" && node["href"]
48
- href = node["href"].to_s.downcase
49
- node.remove_attribute("href") if href.start_with?("javascript:")
109
+ # The name as written in the markup. HTML4 keeps the prefix in the name;
110
+ # HTML5 splits it into a namespace.
111
+ def qualified_name(attribute)
112
+ prefix = attribute.namespace&.prefix
113
+ name = attribute.name.to_s
114
+ (prefix ? "#{prefix}:#{name}" : name).downcase
115
+ end
116
+
117
+ # The parser has already resolved character references, so `value` is literal
118
+ # text. Decoding it again would only corrupt URLs that legitimately contain
119
+ # an escaped entity.
120
+ def unsafe_uri?(value, attribute_name, node)
121
+ normalized = value.gsub(/[\u0000-\u0020]/, "")
122
+ return true if normalized.match?(DANGEROUS_PROTOCOL)
123
+ return false if inline_svg_image?(normalized, attribute_name, node)
124
+ return true if normalized.match?(DANGEROUS_DATA)
125
+
126
+ scheme = normalized[/\A([a-z][a-z0-9+.-]*):/i, 1]
127
+ return false unless scheme
128
+ return false if ALLOWED_PROTOCOLS.include?(scheme.downcase)
129
+ return false if %w[src srcset].include?(attribute_name) &&
130
+ normalized.match?(SAFE_DATA_IMAGE)
131
+
132
+ true
133
+ end
134
+
135
+ def inline_svg_image?(uri, attribute_name, node)
136
+ return false unless IMAGE_SOURCE_ATTRIBUTES.include?(attribute_name)
137
+ return false unless restricted_image_context?(node)
138
+
139
+ uri.match?(INLINE_SVG_DATA)
50
140
  end
51
141
 
52
- # Removes "on*" event handler attributes from any node.
53
- def remove_event_handlers(node)
54
- attrs_to_remove =
55
- node.attributes.keys.select do |name|
56
- name.to_s.downcase.start_with?("on")
57
- end
58
- attrs_to_remove.each { |attr_name| node.remove_attribute(attr_name) }
142
+ def restricted_image_context?(node)
143
+ case node.name.downcase
144
+ when "img" then true
145
+ when "source" then node.parent&.name&.downcase == "picture"
146
+ else false
147
+ end
59
148
  end
60
149
  end
61
150
  end
@@ -1,3 +1,3 @@
1
1
  module UniversalRenderer
2
- VERSION = "0.5.2".freeze
2
+ VERSION = "0.7.0".freeze
3
3
  end
@@ -1,5 +1,6 @@
1
1
  require "universal_renderer/version"
2
2
  require "universal_renderer/configuration"
3
+ require "universal_renderer/instrumentation"
3
4
  require "universal_renderer/ssr/helpers"
4
5
  require "universal_renderer/renderable"
5
6
  require "universal_renderer/engine"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: universal_renderer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thaske
@@ -70,6 +70,13 @@ files:
70
70
  - Rakefile
71
71
  - lib/generators/universal_renderer/install_generator.rb
72
72
  - lib/generators/universal_renderer/templates/initializer.rb
73
+ - lib/generators/universal_renderer/templates/ssr.rake
74
+ - lib/generators/universal_renderer/templates/ssr/config.ts
75
+ - lib/generators/universal_renderer/templates/ssr/dev.ts
76
+ - lib/generators/universal_renderer/templates/ssr/globals.ts
77
+ - lib/generators/universal_renderer/templates/ssr/server.ts
78
+ - lib/generators/universal_renderer/templates/vite.config.ssr.mts
79
+ - lib/generators/universal_renderer/templates/web
73
80
  - lib/tasks/universal_renderer_tasks.rake
74
81
  - lib/universal_renderer.rb
75
82
  - lib/universal_renderer/client/base.rb
@@ -80,6 +87,7 @@ files:
80
87
  - lib/universal_renderer/client/stream/setup.rb
81
88
  - lib/universal_renderer/configuration.rb
82
89
  - lib/universal_renderer/engine.rb
90
+ - lib/universal_renderer/instrumentation.rb
83
91
  - lib/universal_renderer/renderable.rb
84
92
  - lib/universal_renderer/ssr/helpers.rb
85
93
  - lib/universal_renderer/ssr/placeholders.rb