Module: Cataract::ImportResolver

Defined in:
lib/cataract/import_resolver.rb

Overview

Resolves @import statements in CSS Handles fetching imported files and inlining them with proper security controls

Defined Under Namespace

Classes: DefaultFetcher

Constant Summary collapse

SAFE_DEFAULTS =

Default options for safe import resolution (@import statements discovered while parsing a stylesheet - untrusted by default)

{
  max_depth: 5,                      # Prevent infinite recursion
  allowed_schemes: ['https'],        # Only HTTPS by default
  extensions: ['css'],               # Only .css files
  timeout: 10,                       # 10 second timeout for fetches
  follow_redirects: true,            # Follow redirects
  base_path: nil,                    # Base path for resolving relative file imports
  base_uri: nil,                     # Base URI for resolving relative HTTP imports
  fetcher: nil,                      # Custom fetcher (defaults to DefaultFetcher)
  dangerous_path_prefixes: ['/etc/', '/proc/', '/sys/', '/dev/'], # Blocked file:// path prefixes
  allow_local_network: false # Block loopback/private/link-local/metadata addresses (via ssrf_filter)
}.freeze
LOAD_DEFAULTS =

Default options for an explicitly caller-invoked load (Stylesheet#load_uri / #load_file). The caller chose this exact URI/path themselves - a different trust model than @import content discovered inside a stylesheet - so this permits the schemes/extensions load_uri has always supported, while still keeping the dangerous_path_prefixes protection from SAFE_DEFAULTS as a default (callers can still override it).

SAFE_DEFAULTS.merge(
  allowed_schemes: %w[http https file],
  extensions: :any # skip extension validation entirely - see validate_url
).freeze

Class Method Summary collapse

Class Method Details

.normalize_options(options, defaults: SAFE_DEFAULTS) ⇒ Object

Normalize options with safe defaults

Parameters:

  • options (true, Hash)

    true for defaults as-is, or a Hash to merge over them

  • defaults (Hash) (defaults to: SAFE_DEFAULTS)

    Which default profile to merge over (SAFE_DEFAULTS for @import resolution, LOAD_DEFAULTS for an explicit load_uri/load_file call)



120
121
122
123
124
125
126
127
128
129
130
# File 'lib/cataract/import_resolver.rb', line 120

def self.normalize_options(options, defaults: SAFE_DEFAULTS)
  if options == true
    # imports: true -> use defaults as-is
    defaults.dup
  elsif options.is_a?(Hash)
    # imports: { ... } -> merge with defaults
    defaults.merge(options)
  else
    raise ArgumentError, 'imports option must be true or a Hash'
  end
end

.normalize_url(url, base_path: nil, base_uri: nil) ⇒ Object

Normalize URL - handle relative paths and missing schemes Returns a URI object

Parameters:

  • url (String)

    URL to normalize

  • base_path (String, nil) (defaults to: nil)

    Base path for resolving relative file imports

  • base_uri (String, nil) (defaults to: nil)

    Base URI for resolving relative HTTP imports



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
# File 'lib/cataract/import_resolver.rb', line 138

def self.normalize_url(url, base_path: nil, base_uri: nil)
  # Try to parse as-is first
  uri = URI.parse(url)

  # If no scheme, treat as relative path
  if uri.scheme.nil?
    # If we have a base_uri (HTTP/HTTPS), resolve against it
    if base_uri
      base = URI.parse(base_uri)
      uri = base.merge(url)
    elsif url.start_with?('/')
      # Absolute file path
      uri = URI.parse("file://#{url}")
    else
      # Relative file path - make it absolute relative to base_path or current directory
      absolute_path = if base_path
                        File.expand_path(url, base_path)
                      else
                        File.expand_path(url)
                      end
      uri = URI.parse("file://#{absolute_path}")
    end
  end

  uri
rescue URI::InvalidURIError => e
  raise ImportError, "Invalid import URL: #{url} (#{e.message})"
end

.validate_url(url, options) ⇒ Object

Validate URL against security options



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/cataract/import_resolver.rb', line 168

def self.validate_url(url, options)
  uri = normalize_url(url, base_path: options[:base_path], base_uri: options[:base_uri])

  # Check scheme
  unless options[:allowed_schemes].include?(uri.scheme)
    raise ImportError,
          "Import scheme '#{uri.scheme}' not allowed. Allowed schemes: #{options[:allowed_schemes].join(', ')}"
  end

  # Check extension (extensions: :any skips this check entirely - used by
  # LOAD_DEFAULTS, since a directly-invoked load isn't restricted to .css)
  unless options[:extensions] == :any
    path = uri.path || ''
    ext = File.extname(path).delete_prefix('.')

    unless ext.empty? || options[:extensions].include?(ext)
      raise ImportError,
            "Import extension '.#{ext}' not allowed. Allowed extensions: #{options[:extensions].join(', ')}"
    end
  end

  # Additional security checks for file:// scheme
  if uri.scheme == 'file'
    file_path = uri.path

    # Check file exists and is readable
    unless File.exist?(file_path) && File.readable?(file_path)
      raise ImportError, "Import file not found or not readable: #{file_path}"
    end

    # Resolve to a canonical, "..".-free path before the prefix check below -
    # otherwise a path like '/var/../etc/hosts' doesn't literally start with
    # '/etc/' and would slip past it, even though File.exist?/File.read above
    # resolve it to the same file '/etc/hosts' would. Use expand_path (lexical
    # only) rather than realpath: realpath also follows symlinks, and on
    # macOS '/etc' itself is a symlink to '/private/etc', which would make
    # every real path fail to start with '/etc/' and defeat this check entirely.
    canonical_file_path = File.expand_path(file_path)

    # Prevent reading sensitive files (basic check, configurable via
    # options[:dangerous_path_prefixes] - pass [] to disable)
    if options[:dangerous_path_prefixes]&.any? { |prefix| canonical_file_path.start_with?(prefix) }
      raise ImportError, "Import of sensitive system files not allowed: #{file_path}"
    end
  end

  true
rescue URI::InvalidURIError => e
  raise ImportError, "Invalid import URL: #{url} (#{e.message})"
end