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
}.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)



90
91
92
93
94
95
96
97
98
99
100
# File 'lib/cataract/import_resolver.rb', line 90

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



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/cataract/import_resolver.rb', line 108

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



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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/cataract/import_resolver.rb', line 138

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'
    # Resolve to absolute path to prevent directory traversal
    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

    # Prevent reading sensitive files (basic check, configurable via
    # options[:dangerous_path_prefixes] - pass [] to disable)
    if options[:dangerous_path_prefixes]&.any? { |prefix| 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