cataract 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f107809adb744598338be592626fc5ebbfdb81c2813957c7e5ed1652f37a607
4
- data.tar.gz: 810a6aadbe0a8c2ce4eff7c307c17c9de354f1a3bc0d9509ae4e83f1d7e1c8b0
3
+ metadata.gz: b2245bc15a5bd5029bcf6d0cb056a97a896d2855ef247dcc34a3764bba009e89
4
+ data.tar.gz: 170bd30c28c2f1c5405ea742a9398dad5aaba2115cd424c73e17572bed8399d8
5
5
  SHA512:
6
- metadata.gz: b7e85ed2f9ad0b9154e78c795bc0548ca1eaeb55f8f24b2b644852394a506173afb64042f55a3c02c13c21e1b539738695a83c8f774aaf0311bdda8f00da42b9
7
- data.tar.gz: f6ea7361ad3d180aaf9e4faa72bf686906a0f81345df59f70856734e50f17b79d4dc20823d0fae520279767cd289fb8e42bbd619230f095d1d26f9ed95aa1094
6
+ metadata.gz: 329b054911204add270ac0f2853f0d79c0d12c0b549a34acb672fe6b71416b521f500b36403567f65afa620d165b980f6bc0068c661defe75226602b394c4791
7
+ data.tar.gz: b252e03c315de4885224f2bfdd9841ca94e7294b43a80c3c1e27a77d236d451b3ed1a779ad95d956a0445a90d6733d11c8d1ca45ad93d5a693304e7410673d72
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [0.6.0] - 2026-07-30
2
+
3
+ - Security: `ImportResolver.validate_url`'s `dangerous_path_prefixes` check now canonicalizes the `file://` path (via `File.expand_path`) before comparing against blocked prefixes, instead of comparing the raw path string. Previously a path containing `..` that didn't literally start with a blocked prefix (e.g. `file:///var/../etc/passwd`) bypassed the check entirely, even though the file was actually read from the resolved location. Reachable via `Stylesheet#load_uri`/`#load_file` (file scheme allowed by default) and via `@import` when a caller opts `file` into `allowed_schemes`.
4
+ - Security/Breaking: `@import` and `Stylesheet#load_uri`/`#load_file` HTTP(S) fetches are now validated with the [ssrf_filter](https://rubygems.org/gems/ssrf_filter) gem, which blocks loopback, private-network, link-local, and cloud metadata addresses by default and re-validates on every redirect hop. Previously only URL scheme and file extension were checked, so an allowed `https://` URL (or a redirect target) could still reach an internal-only host. Set `allow_local_network: true` to restore the old, unfiltered behavior. Adds `ssrf_filter` as a new runtime dependency.
5
+
1
6
  ## [0.5.0] - 2026-07-09
2
7
 
3
8
  - Feature: `@supports (condition) { ... }` now preserves its condition and the rules it wraps through parse -> serialize, in both backends - previously the condition was discarded entirely and the wrapped rules were silently flattened into the surrounding document with no trace of ever being conditional.
data/README.md CHANGED
@@ -14,7 +14,6 @@ A performant CSS parser for accurate parsing of complex CSS structures.
14
14
  - **CSS Color Level 4**: Parses and preserves modern color formats (hex, rgb, hsl, hwb, oklab, oklch, lab, lch, named colors). Optional color conversion utility for transforming between formats.
15
15
  - **Specificity Calculation**: Automatic CSS specificity computation
16
16
  - **Media Query Filtering**: Query rules by media type
17
- - **Zero Runtime Dependencies**: Pure C extension with no runtime gem dependencies
18
17
 
19
18
  ## Installation
20
19
 
@@ -269,7 +268,8 @@ sheet = Cataract::Stylesheet.parse(css, import: {
269
268
  extensions: ['css'], # Default: ['css']
270
269
  max_depth: 3, # Default: 5
271
270
  timeout: 10, # Default: 10 seconds
272
- follow_redirects: true # Default: true
271
+ follow_redirects: true, # Default: true
272
+ allow_local_network: false # Default: false
273
273
  })
274
274
  ```
275
275
 
@@ -278,6 +278,7 @@ sheet = Cataract::Stylesheet.parse(css, import: {
278
278
  - Non-CSS file extensions
279
279
  - Circular references
280
280
  - Excessive nesting depth
281
+ - SSRF - HTTP(S) fetches are validated with the [ssrf_filter](https://rubygems.org/gems/ssrf_filter) gem, which blocks loopback, private-network, link-local, and cloud metadata addresses by default (re-checked on every redirect hop). Set `allow_local_network: true` to opt out.
281
282
 
282
283
  ## Development
283
284
 
data/cataract.gemspec CHANGED
@@ -27,6 +27,8 @@ Gem::Specification.new do |spec|
27
27
  end
28
28
  end
29
29
 
30
+ spec.add_dependency 'ssrf_filter', '~> 1.5'
31
+
30
32
  spec.bindir = 'exe'
31
33
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
32
34
  spec.require_paths = ['lib']
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'uri'
4
4
  require 'open-uri'
5
+ require 'ssrf_filter'
5
6
 
6
7
  module Cataract
7
8
  # Error raised during import resolution
@@ -38,6 +39,8 @@ module Cataract
38
39
  raise ImportError, "HTTP error fetching import: #{url} (#{e.message})"
39
40
  rescue SocketError => e
40
41
  raise ImportError, "Network error fetching import: #{url} (#{e.message})"
42
+ rescue SsrfFilter::Error => e
43
+ raise ImportError, "Import blocked by SSRF protection: #{url} (#{e.message})"
41
44
  rescue StandardError => e
42
45
  raise ImportError, "Error fetching import: #{url} (#{e.class}: #{e.message})"
43
46
  end
@@ -46,7 +49,16 @@ module Cataract
46
49
 
47
50
  # Fetch content via HTTP/HTTPS
48
51
  def fetch_http(uri, options)
49
- # Use open-uri with timeout
52
+ if options[:allow_local_network]
53
+ fetch_http_unfiltered(uri, options)
54
+ else
55
+ fetch_http_ssrf_safe(uri, options)
56
+ end
57
+ end
58
+
59
+ # Plain open-uri fetch, no SSRF protection - only reachable when the
60
+ # caller explicitly opted in via allow_local_network: true.
61
+ def fetch_http_unfiltered(uri, options)
50
62
  open_uri_options = {
51
63
  read_timeout: options[:timeout],
52
64
  redirect: options[:follow_redirects]
@@ -55,6 +67,23 @@ module Cataract
55
67
  # Use uri.open instead of URI.open to avoid shell command injection
56
68
  uri.open(open_uri_options, &:read)
57
69
  end
70
+
71
+ # Fetch via the ssrf_filter gem, which resolves the hostname and rejects
72
+ # loopback/private/link-local/metadata addresses (re-checked on every
73
+ # redirect hop, since a redirect can point anywhere) before connecting.
74
+ def fetch_http_ssrf_safe(uri, options)
75
+ response = SsrfFilter.get(
76
+ uri,
77
+ http_options: { read_timeout: options[:timeout], open_timeout: options[:timeout] },
78
+ max_redirects: options[:follow_redirects] ? SsrfFilter::DEFAULT_MAX_REDIRECTS : 0
79
+ )
80
+
81
+ unless response.is_a?(Net::HTTPSuccess)
82
+ raise OpenURI::HTTPError.new("#{response.code} #{response.message}", response)
83
+ end
84
+
85
+ response.body
86
+ end
58
87
  end
59
88
 
60
89
  # Default options for safe import resolution (@import statements
@@ -68,7 +97,8 @@ module Cataract
68
97
  base_path: nil, # Base path for resolving relative file imports
69
98
  base_uri: nil, # Base URI for resolving relative HTTP imports
70
99
  fetcher: nil, # Custom fetcher (defaults to DefaultFetcher)
71
- dangerous_path_prefixes: ['/etc/', '/proc/', '/sys/', '/dev/'] # Blocked file:// path prefixes
100
+ dangerous_path_prefixes: ['/etc/', '/proc/', '/sys/', '/dev/'], # Blocked file:// path prefixes
101
+ allow_local_network: false # Block loopback/private/link-local/metadata addresses (via ssrf_filter)
72
102
  }.freeze
73
103
 
74
104
  # Default options for an explicitly caller-invoked load (Stylesheet#load_uri
@@ -158,7 +188,6 @@ module Cataract
158
188
 
159
189
  # Additional security checks for file:// scheme
160
190
  if uri.scheme == 'file'
161
- # Resolve to absolute path to prevent directory traversal
162
191
  file_path = uri.path
163
192
 
164
193
  # Check file exists and is readable
@@ -166,9 +195,18 @@ module Cataract
166
195
  raise ImportError, "Import file not found or not readable: #{file_path}"
167
196
  end
168
197
 
198
+ # Resolve to a canonical, "..".-free path before the prefix check below -
199
+ # otherwise a path like '/var/../etc/hosts' doesn't literally start with
200
+ # '/etc/' and would slip past it, even though File.exist?/File.read above
201
+ # resolve it to the same file '/etc/hosts' would. Use expand_path (lexical
202
+ # only) rather than realpath: realpath also follows symlinks, and on
203
+ # macOS '/etc' itself is a symlink to '/private/etc', which would make
204
+ # every real path fail to start with '/etc/' and defeat this check entirely.
205
+ canonical_file_path = File.expand_path(file_path)
206
+
169
207
  # Prevent reading sensitive files (basic check, configurable via
170
208
  # options[:dangerous_path_prefixes] - pass [] to disable)
171
- if options[:dangerous_path_prefixes]&.any? { |prefix| file_path.start_with?(prefix) }
209
+ if options[:dangerous_path_prefixes]&.any? { |prefix| canonical_file_path.start_with?(prefix) }
172
210
  raise ImportError, "Import of sensitive system files not allowed: #{file_path}"
173
211
  end
174
212
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cataract
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cataract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Cook
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ssrf_filter
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.5'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.5'
12
26
  description: A performant CSS parser with C extensions for accurate parsing of complex
13
27
  CSS structures including media queries, nested selectors, and CSS Color Level 4
14
28
  email: