rhales 0.7.0 → 0.7.1
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/.github/workflows/claude-code-review.yml +4 -0
- data/.github/workflows/claude.yml +4 -0
- data/CHANGELOG.md +37 -0
- data/Gemfile.lock +1 -1
- data/lib/rhales/core/rue_document.rb +15 -0
- data/lib/rhales/core/view.rb +41 -6
- data/lib/rhales/hydration/link_based_injection_detector.rb +47 -24
- data/lib/rhales/hydration/mount_point_detector.rb +75 -26
- data/lib/rhales/security/csp.rb +5 -4
- data/lib/rhales/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ebdcf7b19495050ab5d3af3ba3eda23b719391602816c752afd4dac1110c47c
|
|
4
|
+
data.tar.gz: 976de2823a3d79923937fc19232968ed01e4b6b21623065a2b0136e8d85b9b8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1ccd2220b6a56c25f6f3f22a4c10726a2e82617bdfe3f0a18deab925a13a715e5f6deb0836b9c44570ca3af2b8fb1d512984f3928c9dcadda7a3cc53cb42fa4b
|
|
7
|
+
data.tar.gz: bf62c3740a25ece5609af328afcc8d6e30eea2e07dffce4e55d59c493aad0455177c3edadb2a978de19f57d8d15ac3ff2ff29d3a544eb98a4286712997167184
|
|
@@ -37,6 +37,10 @@ jobs:
|
|
|
37
37
|
with:
|
|
38
38
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
39
39
|
|
|
40
|
+
# Fall back to a current model when the action's default model id is
|
|
41
|
+
# unavailable (the previous default 404s: "model: claude-sonnet-4-20250514")
|
|
42
|
+
fallback_model: "claude-sonnet-4-6"
|
|
43
|
+
|
|
40
44
|
# Direct prompt for automated review (no @claude mention needed)
|
|
41
45
|
direct_prompt: |
|
|
42
46
|
Please review this pull request and provide feedback on:
|
|
@@ -36,6 +36,10 @@ jobs:
|
|
|
36
36
|
with:
|
|
37
37
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
38
38
|
|
|
39
|
+
# Fall back to a current model when the action's default model id is
|
|
40
|
+
# unavailable (the previous default 404s: "model: claude-sonnet-4-20250514")
|
|
41
|
+
fallback_model: "claude-sonnet-4-6"
|
|
42
|
+
|
|
39
43
|
# This is an optional setting that allows Claude to read CI results on PRs
|
|
40
44
|
additional_permissions: |
|
|
41
45
|
actions: read
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.1] - 2026-06-22
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- Validate the schema `window` attribute against a JavaScript identifier pattern
|
|
14
|
+
(`/\A[a-zA-Z_][a-zA-Z0-9_]*\z/`) at parse time; invalid names raise
|
|
15
|
+
`RueDocument::ParseError` (#57).
|
|
16
|
+
- Escape the window name at every hydration render site as defense in depth:
|
|
17
|
+
HTML-escape it in `data-window` / `data-hydration-target` attributes and
|
|
18
|
+
JSON-encode it (`JSONSerializer.dump_html_safe`) in `window[...]` script
|
|
19
|
+
contexts, in both `View` and `LinkBasedInjectionDetector`. Previously an
|
|
20
|
+
unescaped window name could break out of the HTML attribute or JS string (#57).
|
|
21
|
+
- Stop logging the raw CSP nonce value. `CSP.generate_nonce` and
|
|
22
|
+
`CSP#build_header` now log only length/entropy/usage metadata, never the
|
|
23
|
+
per-response secret itself (#57).
|
|
24
|
+
- Escape the remaining interpolated config values in `LinkBasedInjectionDetector`
|
|
25
|
+
for the same reason: `endpoint_url` / `template_name` (HTML-escaped in `href` /
|
|
26
|
+
`data-lazy-src`, JSON-encoded in `fetch(...)` / `import` / loader calls) and the
|
|
27
|
+
lazy `mount_selector` (JSON-encoded in `document.querySelector(...)`), so a
|
|
28
|
+
single/double quote in those config values can no longer break out of its
|
|
29
|
+
context (#59 review).
|
|
30
|
+
- Validate template names in `View#resolve_template_path` to prevent path
|
|
31
|
+
traversal. Names may still use forward slashes for subdirectories, but
|
|
32
|
+
parent-directory references (`..`), embedded null bytes, and absolute paths
|
|
33
|
+
are rejected with `View::TemplateNotFoundError`. This matters because
|
|
34
|
+
`HydrationEndpoint` is designed to be wired to an API route, so a
|
|
35
|
+
request-derived template name can no longer escape the configured template
|
|
36
|
+
directories to read arbitrary files (#22).
|
|
37
|
+
|
|
38
|
+
### Performance
|
|
39
|
+
- `MountPointDetector#detect` now scans the rendered HTML a single time using
|
|
40
|
+
one combined alternation pattern built from all selectors, instead of
|
|
41
|
+
re-scanning the whole document once per selector. Results are memoized per
|
|
42
|
+
detector instance keyed by `[template_html, selectors]`, and `View` reuses a
|
|
43
|
+
single detector, so repeated detection on identical HTML reuses the prior
|
|
44
|
+
result and its `SafeInjectionValidator` work. Selection semantics
|
|
45
|
+
(selector-priority tie-breaking, earliest safe position) are unchanged (#22).
|
|
46
|
+
|
|
10
47
|
## [0.7.0] - 2026-06-21
|
|
11
48
|
|
|
12
49
|
### Security
|
data/Gemfile.lock
CHANGED
|
@@ -41,6 +41,11 @@ module Rhales
|
|
|
41
41
|
# Known schema section attributes
|
|
42
42
|
KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends src].freeze
|
|
43
43
|
|
|
44
|
+
# The window attribute names a global JavaScript property and is interpolated
|
|
45
|
+
# into HTML attribute and <script> contexts during hydration. Restrict it to a
|
|
46
|
+
# valid JavaScript identifier so it cannot break out of either context (issue #57).
|
|
47
|
+
WINDOW_ATTRIBUTE_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
|
|
48
|
+
|
|
44
49
|
attr_reader :content, :file_path, :grammar, :ast
|
|
45
50
|
|
|
46
51
|
def initialize(content, file_path = nil)
|
|
@@ -289,6 +294,7 @@ module Rhales
|
|
|
289
294
|
validate_schema_attributes!
|
|
290
295
|
# Set default window attribute for schema section
|
|
291
296
|
@schema_attributes['window'] ||= 'data'
|
|
297
|
+
validate_window_attribute!
|
|
292
298
|
|
|
293
299
|
# Schema sections require lang attribute
|
|
294
300
|
unless @schema_attributes['lang']
|
|
@@ -305,6 +311,15 @@ module Rhales
|
|
|
305
311
|
end
|
|
306
312
|
end
|
|
307
313
|
|
|
314
|
+
def validate_window_attribute!
|
|
315
|
+
window = @schema_attributes['window']
|
|
316
|
+
return if window.is_a?(String) && window.match?(WINDOW_ATTRIBUTE_PATTERN)
|
|
317
|
+
|
|
318
|
+
raise ParseError,
|
|
319
|
+
"Invalid window attribute #{window.inspect}: must be a valid JavaScript " \
|
|
320
|
+
"identifier matching #{WINDOW_ATTRIBUTE_PATTERN.source}"
|
|
321
|
+
end
|
|
322
|
+
|
|
308
323
|
def warn_unknown_schema_attribute(attribute)
|
|
309
324
|
file_info = @file_path ? " in #{@file_path}" : ''
|
|
310
325
|
warn "Warning: schema section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
|
data/lib/rhales/core/view.rb
CHANGED
|
@@ -229,6 +229,8 @@ module Rhales
|
|
|
229
229
|
|
|
230
230
|
# Resolve template path
|
|
231
231
|
def resolve_template_path(template_name)
|
|
232
|
+
validate_template_name!(template_name)
|
|
233
|
+
|
|
232
234
|
# Check configured template paths first
|
|
233
235
|
if config && config.template_paths && !config.template_paths.empty?
|
|
234
236
|
config.template_paths.each do |path|
|
|
@@ -254,6 +256,28 @@ module Rhales
|
|
|
254
256
|
end
|
|
255
257
|
end
|
|
256
258
|
|
|
259
|
+
# Guard against path-traversal in template names.
|
|
260
|
+
#
|
|
261
|
+
# Template names may contain forward slashes to address subdirectories
|
|
262
|
+
# (e.g. "web/homepage"), but must not reference parent directories, embed
|
|
263
|
+
# null bytes, or be absolute paths. HydrationEndpoint exposes template
|
|
264
|
+
# names to API callers, so a name derived from request input must never be
|
|
265
|
+
# able to escape the configured template directories and read arbitrary
|
|
266
|
+
# files off disk.
|
|
267
|
+
def validate_template_name!(template_name)
|
|
268
|
+
unless template_name.is_a?(String) && !template_name.empty?
|
|
269
|
+
raise TemplateNotFoundError, "Invalid template name: #{template_name.inspect}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
absolute = template_name.start_with?('/') || template_name.match?(/\A[a-zA-Z]:[\\\/]/)
|
|
273
|
+
null_byte = template_name.bytes.include?(0)
|
|
274
|
+
traversal = template_name.split(%r{[\\/]}).include?('..')
|
|
275
|
+
|
|
276
|
+
if absolute || null_byte || traversal
|
|
277
|
+
raise TemplateNotFoundError, "Unsafe template name: #{template_name.inspect}"
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
257
281
|
# Get templates root directory
|
|
258
282
|
def templates_root
|
|
259
283
|
boot_root = File.expand_path('../../..', __dir__)
|
|
@@ -317,8 +341,14 @@ module Rhales
|
|
|
317
341
|
return nil unless config&.hydration
|
|
318
342
|
|
|
319
343
|
custom_selectors = config.hydration.mount_point_selectors || []
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
mount_point_detector.detect(template_html, custom_selectors)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Memoized mount point detector. Reusing one instance lets its per-instance
|
|
348
|
+
# result cache avoid rebuilding a SafeInjectionValidator and re-scanning
|
|
349
|
+
# identical rendered HTML across calls within this view's lifetime.
|
|
350
|
+
def mount_point_detector
|
|
351
|
+
@mount_point_detector ||= MountPointDetector.new
|
|
322
352
|
end
|
|
323
353
|
|
|
324
354
|
# Build view composition for the given template
|
|
@@ -387,26 +417,31 @@ module Rhales
|
|
|
387
417
|
unique_id = "rsfc-data-#{SecureRandom.hex(8)}"
|
|
388
418
|
nonce_attr = nonce_attribute
|
|
389
419
|
|
|
420
|
+
# Escape the window name for its two output contexts (issue #57). Parse-time
|
|
421
|
+
# validation already restricts it to a JS identifier; this is defense in depth.
|
|
422
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
423
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
424
|
+
|
|
390
425
|
# Create JSON script tag with optional reflection attributes
|
|
391
|
-
json_attrs = reflection_enabled? ? " data-window=\"#{
|
|
426
|
+
json_attrs = reflection_enabled? ? " data-window=\"#{window_attr_html}\"" : ''
|
|
392
427
|
json_script = <<~HTML.strip
|
|
393
428
|
<script#{nonce_attr} id="#{unique_id}" type="application/json"#{json_attrs}>#{JSONSerializer.dump_html_safe(data)}</script>
|
|
394
429
|
HTML
|
|
395
430
|
|
|
396
431
|
# Create hydration script with optional reflection attributes
|
|
397
|
-
hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{
|
|
432
|
+
hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr_html}\"" : ''
|
|
398
433
|
hydration_script = if reflection_enabled?
|
|
399
434
|
<<~HTML.strip
|
|
400
435
|
<script#{nonce_attr}#{hydration_attrs}>
|
|
401
436
|
var dataScript = document.getElementById('#{unique_id}');
|
|
402
|
-
var targetName = dataScript.getAttribute('data-window') ||
|
|
437
|
+
var targetName = dataScript.getAttribute('data-window') || #{window_attr_js};
|
|
403
438
|
window[targetName] = JSON.parse(dataScript.textContent);
|
|
404
439
|
</script>
|
|
405
440
|
HTML
|
|
406
441
|
else
|
|
407
442
|
<<~HTML.strip
|
|
408
443
|
<script#{nonce_attr}#{hydration_attrs}>
|
|
409
|
-
window[
|
|
444
|
+
window[#{window_attr_js}] = JSON.parse(document.getElementById('#{unique_id}').textContent);
|
|
410
445
|
</script>
|
|
411
446
|
HTML
|
|
412
447
|
end
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'strscan'
|
|
6
|
+
require 'erb'
|
|
6
7
|
require_relative 'safe_injection_validator'
|
|
8
|
+
require_relative '../utils/json_serializer'
|
|
7
9
|
|
|
8
10
|
module Rhales
|
|
9
11
|
# Generates link-based hydration strategies that use browser resource hints
|
|
@@ -47,12 +49,16 @@ module Rhales
|
|
|
47
49
|
|
|
48
50
|
def generate_basic_link(template_name, window_attr, nonce)
|
|
49
51
|
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
52
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
53
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
54
|
+
endpoint_url_html = ERB::Util.html_escape(endpoint_url)
|
|
55
|
+
endpoint_url_js = JSONSerializer.dump_html_safe(endpoint_url)
|
|
50
56
|
|
|
51
|
-
link_tag = %(<link href="#{
|
|
57
|
+
link_tag = %(<link href="#{endpoint_url_html}" type="application/json">)
|
|
52
58
|
|
|
53
59
|
script_tag = <<~HTML.strip
|
|
54
|
-
<script#{nonce_attribute(nonce)} data-hydration-target="#{
|
|
55
|
-
// Load hydration data
|
|
60
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
|
|
61
|
+
// Load hydration data
|
|
56
62
|
window.__rhales__ = window.__rhales__ || {};
|
|
57
63
|
if (!window.__rhales__.loadData) {
|
|
58
64
|
window.__rhales__.loadData = function(target, url) {
|
|
@@ -61,7 +67,7 @@ module Rhales
|
|
|
61
67
|
.then(data => window[target] = data);
|
|
62
68
|
};
|
|
63
69
|
}
|
|
64
|
-
window.__rhales__.loadData(
|
|
70
|
+
window.__rhales__.loadData(#{window_attr_js}, #{endpoint_url_js});
|
|
65
71
|
</script>
|
|
66
72
|
HTML
|
|
67
73
|
|
|
@@ -71,12 +77,16 @@ module Rhales
|
|
|
71
77
|
def generate_prefetch_link(template_name, window_attr, nonce)
|
|
72
78
|
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
73
79
|
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
|
80
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
81
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
82
|
+
endpoint_url_html = ERB::Util.html_escape(endpoint_url)
|
|
83
|
+
endpoint_url_js = JSONSerializer.dump_html_safe(endpoint_url)
|
|
74
84
|
|
|
75
|
-
link_tag = %(<link rel="prefetch" href="#{
|
|
85
|
+
link_tag = %(<link rel="prefetch" href="#{endpoint_url_html}" as="fetch"#{crossorigin_attr}>)
|
|
76
86
|
|
|
77
87
|
script_tag = <<~HTML.strip
|
|
78
|
-
<script#{nonce_attribute(nonce)} data-hydration-target="#{
|
|
79
|
-
// Prefetch hydration data
|
|
88
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
|
|
89
|
+
// Prefetch hydration data
|
|
80
90
|
window.__rhales__ = window.__rhales__ || {};
|
|
81
91
|
if (!window.__rhales__.loadPrefetched) {
|
|
82
92
|
window.__rhales__.loadPrefetched = function(target, url) {
|
|
@@ -85,7 +95,7 @@ module Rhales
|
|
|
85
95
|
.then(data => window[target] = data);
|
|
86
96
|
};
|
|
87
97
|
}
|
|
88
|
-
window.__rhales__.loadPrefetched(
|
|
98
|
+
window.__rhales__.loadPrefetched(#{window_attr_js}, #{endpoint_url_js});
|
|
89
99
|
</script>
|
|
90
100
|
HTML
|
|
91
101
|
|
|
@@ -95,19 +105,23 @@ module Rhales
|
|
|
95
105
|
def generate_preload_link(template_name, window_attr, nonce)
|
|
96
106
|
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
97
107
|
crossorigin_attr = @crossorigin_enabled ? ' crossorigin' : ''
|
|
108
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
109
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
110
|
+
endpoint_url_html = ERB::Util.html_escape(endpoint_url)
|
|
111
|
+
endpoint_url_js = JSONSerializer.dump_html_safe(endpoint_url)
|
|
98
112
|
|
|
99
|
-
link_tag = %(<link rel="preload" href="#{
|
|
113
|
+
link_tag = %(<link rel="preload" href="#{endpoint_url_html}" as="fetch"#{crossorigin_attr}>)
|
|
100
114
|
|
|
101
115
|
script_tag = <<~HTML.strip
|
|
102
|
-
<script#{nonce_attribute(nonce)} data-hydration-target="#{
|
|
116
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
|
|
103
117
|
// Preload strategy - high priority fetch
|
|
104
|
-
fetch(
|
|
118
|
+
fetch(#{endpoint_url_js})
|
|
105
119
|
.then(r => r.json())
|
|
106
120
|
.then(data => {
|
|
107
|
-
window[
|
|
121
|
+
window[#{window_attr_js}] = data;
|
|
108
122
|
// Dispatch ready event
|
|
109
123
|
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
110
|
-
detail: { target:
|
|
124
|
+
detail: { target: #{window_attr_js}, data: data }
|
|
111
125
|
}));
|
|
112
126
|
})
|
|
113
127
|
.catch(err => console.error('Rhales hydration error:', err));
|
|
@@ -119,18 +133,22 @@ module Rhales
|
|
|
119
133
|
|
|
120
134
|
def generate_modulepreload_link(template_name, window_attr, nonce)
|
|
121
135
|
endpoint_url = "#{@api_endpoint_path}/#{template_name}.js"
|
|
136
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
137
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
138
|
+
endpoint_url_html = ERB::Util.html_escape(endpoint_url)
|
|
139
|
+
endpoint_url_js = JSONSerializer.dump_html_safe(endpoint_url)
|
|
122
140
|
|
|
123
|
-
link_tag = %(<link rel="modulepreload" href="#{
|
|
141
|
+
link_tag = %(<link rel="modulepreload" href="#{endpoint_url_html}">)
|
|
124
142
|
|
|
125
143
|
script_tag = <<~HTML.strip
|
|
126
|
-
<script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{
|
|
144
|
+
<script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
|
|
127
145
|
// Module preload strategy
|
|
128
|
-
import data from
|
|
129
|
-
window[
|
|
146
|
+
import data from #{endpoint_url_js};
|
|
147
|
+
window[#{window_attr_js}] = data;
|
|
130
148
|
|
|
131
149
|
// Dispatch ready event
|
|
132
150
|
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
133
|
-
detail: { target:
|
|
151
|
+
detail: { target: #{window_attr_js}, data: data }
|
|
134
152
|
}));
|
|
135
153
|
</script>
|
|
136
154
|
HTML
|
|
@@ -141,28 +159,33 @@ module Rhales
|
|
|
141
159
|
def generate_lazy_loading(template_name, window_attr, nonce)
|
|
142
160
|
endpoint_url = "#{@api_endpoint_path}/#{template_name}"
|
|
143
161
|
mount_selector = @hydration_config.lazy_mount_selector || '#app'
|
|
162
|
+
window_attr_html = ERB::Util.html_escape(window_attr)
|
|
163
|
+
window_attr_js = JSONSerializer.dump_html_safe(window_attr)
|
|
164
|
+
endpoint_url_html = ERB::Util.html_escape(endpoint_url)
|
|
165
|
+
endpoint_url_js = JSONSerializer.dump_html_safe(endpoint_url)
|
|
166
|
+
mount_selector_js = JSONSerializer.dump_html_safe(mount_selector)
|
|
144
167
|
|
|
145
168
|
# No link tag for lazy loading - purely script-driven
|
|
146
169
|
script_tag = <<~HTML.strip
|
|
147
|
-
<script#{nonce_attribute(nonce)} data-hydration-target="#{
|
|
170
|
+
<script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}" data-lazy-src="#{endpoint_url_html}">
|
|
148
171
|
// Lazy loading strategy with intersection observer
|
|
149
172
|
window.__rhales__ = window.__rhales__ || {};
|
|
150
173
|
window.__rhales__.initLazyLoading = function() {
|
|
151
|
-
const mountElement = document.querySelector(
|
|
174
|
+
const mountElement = document.querySelector(#{mount_selector_js});
|
|
152
175
|
if (!mountElement) {
|
|
153
|
-
console.warn('Rhales: Mount element
|
|
176
|
+
console.warn('Rhales: Mount element ' + #{mount_selector_js} + ' not found for lazy loading');
|
|
154
177
|
return;
|
|
155
178
|
}
|
|
156
179
|
|
|
157
180
|
const observer = new IntersectionObserver((entries) => {
|
|
158
181
|
entries.forEach(entry => {
|
|
159
182
|
if (entry.isIntersecting) {
|
|
160
|
-
fetch(
|
|
183
|
+
fetch(#{endpoint_url_js})
|
|
161
184
|
.then(r => r.json())
|
|
162
185
|
.then(data => {
|
|
163
|
-
window[
|
|
186
|
+
window[#{window_attr_js}] = data;
|
|
164
187
|
window.dispatchEvent(new CustomEvent('rhales:hydrated', {
|
|
165
|
-
detail: { target:
|
|
188
|
+
detail: { target: #{window_attr_js}, data: data }
|
|
166
189
|
}));
|
|
167
190
|
})
|
|
168
191
|
.catch(err => console.error('Rhales lazy hydration error:', err));
|
|
@@ -21,43 +21,92 @@ module Rhales
|
|
|
21
21
|
#
|
|
22
22
|
# Default selectors are checked: ['#app', '#root', '[data-rsfc-mount]', '[data-mount]']
|
|
23
23
|
# Custom selectors can be added via configuration and are combined with defaults.
|
|
24
|
+
#
|
|
25
|
+
# ## Performance
|
|
26
|
+
#
|
|
27
|
+
# Detection scans the HTML a single time using one combined alternation
|
|
28
|
+
# pattern built from all selectors, rather than re-scanning the whole
|
|
29
|
+
# document once per selector. Results are memoized per instance keyed by
|
|
30
|
+
# [template_html, selectors], so repeated detection on the same rendered
|
|
31
|
+
# HTML (e.g. across renders that reuse the detector) reuses the prior result
|
|
32
|
+
# and its SafeInjectionValidator work instead of recomputing.
|
|
24
33
|
class MountPointDetector
|
|
25
34
|
DEFAULT_SELECTORS = ['#app', '#root', '[data-rsfc-mount]', '[data-mount]'].freeze
|
|
26
35
|
|
|
36
|
+
def initialize
|
|
37
|
+
@cache = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
27
40
|
def detect(template_html, custom_selectors = [])
|
|
28
41
|
selectors = (DEFAULT_SELECTORS + Array(custom_selectors)).uniq
|
|
29
|
-
|
|
42
|
+
cache_key = [template_html, selectors]
|
|
43
|
+
return @cache[cache_key] if @cache.key?(cache_key)
|
|
44
|
+
|
|
45
|
+
@cache[cache_key] = compute_detection(template_html, selectors)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def compute_detection(template_html, selectors)
|
|
30
51
|
validator = SafeInjectionValidator.new(template_html)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
end
|
|
52
|
+
pattern = combined_pattern(selectors)
|
|
53
|
+
scanner = StringScanner.new(template_html)
|
|
54
|
+
matches = []
|
|
55
|
+
|
|
56
|
+
# Single pass over the HTML: the combined alternation finds every
|
|
57
|
+
# selector occurrence in document order, and the matching capture-group
|
|
58
|
+
# index tells us which selector produced it.
|
|
59
|
+
while scanner.scan_until(pattern)
|
|
60
|
+
selector = selectors[matched_group_index(scanner, selectors.length)]
|
|
61
|
+
tag_start = find_tag_start(scanner, template_html)
|
|
62
|
+
|
|
63
|
+
# Only include mount points that are safe for injection
|
|
64
|
+
safe_position = find_safe_injection_position(validator, tag_start)
|
|
65
|
+
next unless safe_position
|
|
66
|
+
|
|
67
|
+
matches << {
|
|
68
|
+
selector: selector,
|
|
69
|
+
position: safe_position,
|
|
70
|
+
original_position: tag_start,
|
|
71
|
+
matched: scanner.matched,
|
|
72
|
+
}
|
|
53
73
|
end
|
|
54
74
|
|
|
55
|
-
#
|
|
56
|
-
|
|
75
|
+
# Preserve the original selection semantics: matches are considered in
|
|
76
|
+
# selector-priority order (then document order within a selector), and
|
|
77
|
+
# the earliest injection position wins. Ordering the matches this way
|
|
78
|
+
# before min_by reproduces the previous per-selector tie-breaking.
|
|
79
|
+
ordered = selectors.flat_map { |selector| matches.select { |mp| mp[:selector] == selector } }
|
|
80
|
+
ordered.min_by { |mp| mp[:position] }
|
|
57
81
|
end
|
|
58
82
|
|
|
59
|
-
|
|
83
|
+
# Build one alternation pattern from all selectors. Each selector's
|
|
84
|
+
# sub-pattern is wrapped in a capture group so the matched group index
|
|
85
|
+
# identifies which selector matched. None of the sub-patterns introduce
|
|
86
|
+
# their own capture groups, so group N corresponds to selector N-1.
|
|
87
|
+
def combined_pattern(selectors)
|
|
88
|
+
union = selectors.map { |selector| "(#{build_pattern(selector).source})" }.join('|')
|
|
89
|
+
Regexp.new(union, Regexp::IGNORECASE)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def matched_group_index(scanner, count)
|
|
93
|
+
count.times { |index| return index if scanner[index + 1] }
|
|
94
|
+
|
|
95
|
+
# Unreachable in normal operation: after a successful scan_until against
|
|
96
|
+
# combined_pattern exactly one wrapped selector group has captured. If
|
|
97
|
+
# none has, a build_pattern sub-pattern introduced its own capturing
|
|
98
|
+
# group and shifted the numbering, so fail loudly rather than silently
|
|
99
|
+
# attributing the match to the first selector.
|
|
100
|
+
raise 'MountPointDetector: no selector capture group matched; ' \
|
|
101
|
+
'build_pattern sub-patterns must not contain capturing groups'
|
|
102
|
+
end
|
|
60
103
|
|
|
104
|
+
# Build the regex fragment that matches a single selector.
|
|
105
|
+
#
|
|
106
|
+
# INVARIANT: sub-patterns must not contain capturing groups. combined_pattern
|
|
107
|
+
# wraps each fragment in exactly one capture group and maps that group's
|
|
108
|
+
# index back to a selector, so any stray `(...)` here would shift the
|
|
109
|
+
# numbering and misattribute matches. Use non-capturing groups `(?:...)`.
|
|
61
110
|
def build_pattern(selector)
|
|
62
111
|
case selector
|
|
63
112
|
when /^#(.+)$/
|
data/lib/rhales/security/csp.rb
CHANGED
|
@@ -47,10 +47,10 @@ module Rhales
|
|
|
47
47
|
|
|
48
48
|
header = policy_directives.join('; ')
|
|
49
49
|
|
|
50
|
-
# Log CSP header generation for security audit
|
|
50
|
+
# Log CSP header generation for security audit. The nonce is a per-response
|
|
51
|
+
# secret, so log only whether one was used, never its value (issue #57).
|
|
51
52
|
log_with_metadata(Rhales.logger, :debug, "CSP header generated",
|
|
52
53
|
nonce_used: nonce_used,
|
|
53
|
-
nonce: @nonce,
|
|
54
54
|
directive_count: policy_directives.size,
|
|
55
55
|
header_length: header.length
|
|
56
56
|
)
|
|
@@ -62,8 +62,9 @@ module Rhales
|
|
|
62
62
|
def self.generate_nonce
|
|
63
63
|
nonce = SecureRandom.hex(16)
|
|
64
64
|
|
|
65
|
-
# Log nonce generation for security audit trail
|
|
66
|
-
|
|
65
|
+
# Log nonce generation for security audit trail. Never log the nonce value
|
|
66
|
+
# itself — it is a per-response secret (issue #57).
|
|
67
|
+
Rhales.logger.debug("CSP nonce generated: length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
|
|
67
68
|
|
|
68
69
|
nonce
|
|
69
70
|
end
|
data/lib/rhales/version.rb
CHANGED