lib/sanitize/css.rb



# encoding: utf-8

require 'crass'
require 'set'

class Sanitize; class CSS
  attr_reader :config

  # Names of CSS at-rules whose blocks may contain properties.
  AT_RULES_WITH_PROPERTIES = Set.new(%w[font-face page])

  # Names of CSS at-rules whose blocks may contain style rules.
  AT_RULES_WITH_STYLES = Set.new(%w[document media supports])

  # -- Class Methods -----------------------------------------------------------

  # Sanitizes inline CSS style properties.
  #
  # This is most useful for sanitizing non-stylesheet fragments of CSS like you
  # would find in the `style` attribute of an HTML element. To sanitize a full
  # CSS stylesheet, use {.stylesheet}.
  #
  # @example
  #   Sanitize::CSS.properties("background: url(foo.png); color: #fff;")
  #
  # @return [String] Sanitized CSS properties.
  def self.properties(css, config = {})
    self.new(config).properties(css)
  end

  def self.stylesheet(css, config = {})
    self.new(config).stylesheet(css)
  end

  def self.tree!(tree, config = {})
    self.new(config).tree!(tree)
  end

  # -- Instance Methods --------------------------------------------------------

  # Returns a new Sanitize::CSS object initialized with the settings in
  # _config_.
  def initialize(config = {})
    @config = Config.merge(Config::DEFAULT[:css], config[:css] || config)
  end

  # Sanitizes inline CSS style properties.
  #
  # This is most useful for sanitizing non-stylesheet fragments of CSS like you
  # would find in the `style` attribute of an HTML element. To sanitize a full
  # CSS stylesheet, use {#stylesheet}.
  #
  # @example
  #   scss = Sanitize::CSS.new(Sanitize::Config::RELAXED)
  #   scss.properties("background: url(foo.png); color: #fff;")
  #
  # @return [String] Sanitized CSS properties.
  def properties(css)
    tree = Crass.parse_properties(css,
      :preserve_comments => @config[:allow_comments],
      :preserve_hacks    => @config[:allow_hacks])

    tree!(tree)
    Crass::Parser.stringify(tree)
  end

  # Sanitizes a full CSS stylesheet.
  #
  # A stylesheet may include selectors, @ rules, and comments. To sanitize only
  # inline style properties such as the contents of an HTML `style` attribute,
  # use {#properties}.
  #
  # @example
  #   css = %[
  #     .foo {
  #       background: url(foo.png);
  #       color: #fff;
  #     }
  #
  #     #bar {
  #       font: 42pt 'Comic Sans MS';
  #     }
  #   ]
  #
  #   scss = Sanitize::CSS.new(Sanitize::Config::RELAXED)
  #   scss.stylesheet(css)
  #
  # @return [String] Sanitized CSS stylesheet.
  def stylesheet(css)
    tree = Crass.parse(css,
      :preserve_comments => @config[:allow_comments],
      :preserve_hacks    => @config[:allow_hacks])

    tree!(tree)
    Crass::Parser.stringify(tree)
  end

  # Sanitizes the given Crass CSS parse tree and all its children, modifying it
  # in place.
  #
  # @example
  #   scss = Sanitize::CSS.new(Sanitize::Config::RELAXED)
  #   tree = Crass.parse(css)
  #
  #   scss.tree!(tree)
  #
  # @return [Array] Sanitized Crass CSS parse tree.
  def tree!(tree)
    tree.map! do |node|
      next nil if node.nil?

      case node[:node]
      when :at_rule
        next at_rule!(node)

      when :comment
        next node if @config[:allow_comments]

      when :property
        next property!(node)

      when :style_rule
        tree!(node[:children])
        next node

      when :whitespace
        next node
      end

      nil
    end

    tree
  end

  # -- Protected Instance Methods ----------------------------------------------
  protected

  # Sanitizes a CSS at-rule node. Returns the sanitized node, or `nil` if the
  # current config doesn't allow this at-rule.
  def at_rule!(rule)
    name = rule[:name].downcase
    return nil unless @config[:at_rules].include?(name)

    if AT_RULES_WITH_STYLES.include?(name)
      styles = Crass::Parser.parse_rules(rule[:block][:value],
        :preserve_comments => @config[:allow_comments],
        :preserve_hacks    => @config[:allow_hacks])

      rule[:block][:value] = tree!(styles)

    elsif AT_RULES_WITH_PROPERTIES.include?(name)
      props = Crass::Parser.parse_properties(rule[:block][:value],
        :preserve_comments => @config[:allow_comments],
        :preserve_hacks    => @config[:allow_hacks])

      rule[:block][:value] = tree!(props)

    else
      rule.delete(:block)
    end

    rule
  end

  # Sanitizes a CSS property node. Returns the sanitized node, or `nil` if the
  # current config doesn't allow this property.
  def property!(prop)
    name = prop[:name].downcase

    # Preserve IE * and _ hacks if desired.
    if @config[:allow_hacks]
      name.slice!(0) if name =~ /\A[*_]/
    end

    return nil unless @config[:properties].include?(name)

    nodes          = prop[:children].dup
    combined_value = ''

    nodes.each do |child|
      value = child[:value]

      case child[:node]
      when :ident
        combined_value << value if String === value

      when :function
        if child.key?(:name)
          return nil if child[:name].downcase == 'expression'
        end

        if Array === value
          nodes.concat(value)
        elsif String === value
          combined_value << value

          if value.downcase == 'expression' || combined_value.downcase == 'expression'
            return nil
          end
        end

      when :url
        if value =~ Sanitize::REGEX_PROTOCOL
          return nil unless @config[:protocols].include?($1.downcase)
        else
          return nil unless @config[:protocols].include?(:relative)
        end

      when :bad_url
        return nil
      end
    end

    prop
  end

end; end