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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc4ab2b108b173b1781fb48ada4b2cfba22547d79d0ecc4659838a67c2407363
4
- data.tar.gz: 59f4e4b981c3166722d3ddebeba27861dc949575719c8eaa53d23b4e6a41f8d2
3
+ metadata.gz: 3ebdcf7b19495050ab5d3af3ba3eda23b719391602816c752afd4dac1110c47c
4
+ data.tar.gz: 976de2823a3d79923937fc19232968ed01e4b6b21623065a2b0136e8d85b9b8e
5
5
  SHA512:
6
- metadata.gz: 06547d4e90656b20b5dd8dee5f6b468ea5ff63fc1f76becdca3b48889af7f62a42793acca89ced4f32f91017eb026f18862d772ffa1c606582a4aa0ad8fbb490
7
- data.tar.gz: 1d5eb58558b5b45c3c24c95f2ee0f26ba171e6c11553e3b55d41aa598c41f2f53573b7e05c36abd52865d7b884aa571a076698183a4a1b135f31ec1a29b3319c
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rhales (0.7.0)
4
+ rhales (0.7.1)
5
5
  json_schemer (~> 2)
6
6
  logger
7
7
  tilt (~> 2)
@@ -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}"
@@ -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
- detector = MountPointDetector.new
321
- detector.detect(template_html, custom_selectors)
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=\"#{window_attr}\"" : ''
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=\"#{window_attr}\"" : ''
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') || '#{window_attr}';
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['#{window_attr}'] = JSON.parse(document.getElementById('#{unique_id}').textContent);
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="#{endpoint_url}" type="application/json">)
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="#{window_attr}">
55
- // Load hydration data for #{window_attr}
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('#{window_attr}', '#{endpoint_url}');
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="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
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="#{window_attr}">
79
- // Prefetch hydration data for #{window_attr}
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('#{window_attr}', '#{endpoint_url}');
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="#{endpoint_url}" as="fetch"#{crossorigin_attr}>)
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="#{window_attr}">
116
+ <script#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
103
117
  // Preload strategy - high priority fetch
104
- fetch('#{endpoint_url}')
118
+ fetch(#{endpoint_url_js})
105
119
  .then(r => r.json())
106
120
  .then(data => {
107
- window['#{window_attr}'] = data;
121
+ window[#{window_attr_js}] = data;
108
122
  // Dispatch ready event
109
123
  window.dispatchEvent(new CustomEvent('rhales:hydrated', {
110
- detail: { target: '#{window_attr}', data: data }
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="#{endpoint_url}">)
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="#{window_attr}">
144
+ <script type="module"#{nonce_attribute(nonce)} data-hydration-target="#{window_attr_html}">
127
145
  // Module preload strategy
128
- import data from '#{endpoint_url}';
129
- window['#{window_attr}'] = data;
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: '#{window_attr}', data: data }
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="#{window_attr}" data-lazy-src="#{endpoint_url}">
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('#{mount_selector}');
174
+ const mountElement = document.querySelector(#{mount_selector_js});
152
175
  if (!mountElement) {
153
- console.warn('Rhales: Mount element "#{mount_selector}" not found for lazy loading');
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('#{endpoint_url}')
183
+ fetch(#{endpoint_url_js})
161
184
  .then(r => r.json())
162
185
  .then(data => {
163
- window['#{window_attr}'] = data;
186
+ window[#{window_attr_js}] = data;
164
187
  window.dispatchEvent(new CustomEvent('rhales:hydrated', {
165
- detail: { target: '#{window_attr}', data: data }
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
- scanner = StringScanner.new(template_html)
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
- mount_points = []
32
-
33
- selectors.each do |selector|
34
- scanner.pos = 0
35
- pattern = build_pattern(selector)
36
-
37
- while scanner.scan_until(pattern)
38
- # Calculate position where the full tag starts
39
- tag_start_pos = find_tag_start(scanner, template_html)
40
-
41
- # Only include mount points that are safe for injection
42
- safe_position = find_safe_injection_position(validator, tag_start_pos)
43
-
44
- if safe_position
45
- mount_points << {
46
- selector: selector,
47
- position: safe_position,
48
- original_position: tag_start_pos,
49
- matched: scanner.matched
50
- }
51
- end
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
- # Return earliest mount point by position
56
- mount_points.min_by { |mp| mp[:position] }
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
- private
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 /^#(.+)$/
@@ -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
- Rhales.logger.debug("CSP nonce generated: nonce=#{nonce} length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
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
@@ -5,6 +5,6 @@
5
5
  module Rhales
6
6
  # Version information for the RSFC gem
7
7
  unless defined?(Rhales::VERSION)
8
- VERSION = '0.7.0'
8
+ VERSION = '0.7.1'
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rhales
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - delano