# frozen_string_literal: true
require_relative "policy_management"
require_relative "content_security_policy_config"

module SecureHeaders
  class ContentSecurityPolicy
    include PolicyManagement

    def initialize(config = nil)
      @config =
        if config.is_a?(Hash)
          if config[:report_only]
            ContentSecurityPolicyReportOnlyConfig.new(config || DEFAULT_CONFIG)
          else
            ContentSecurityPolicyConfig.new(config || DEFAULT_CONFIG)
          end
        elsif config.nil?
          ContentSecurityPolicyConfig.new(DEFAULT_CONFIG)
        else
          config
        end

      @preserve_schemes = @config[:preserve_schemes]
      @script_nonce = @config[:script_nonce]
      @style_nonce = @config[:style_nonce]
    end

    ##
    # Returns the name to use for the header. Either "content-security-policy" or
    # "content-security-policy-report-only"
    def name
      @config.class.const_get(:HEADER_NAME)
    end

    ##
    # Return the value of the CSP header
    def value
      @value ||=
        if @config
          build_value
        else
          DEFAULT_VALUE
        end
    end

    private

    # Private: converts the config object into a string representing a policy.
    # Places default-src at the first directive and report-uri as the last. All
    # others are presented in alphabetical order.
    #
    # Returns a content security policy header value.
    def build_value
      directives.map do |directive_name|
        case DIRECTIVE_VALUE_TYPES[directive_name]
        when :source_list,
             :require_sri_for_list, # require_sri is a simple set of strings that don't need to deal with symbol casing
             :require_trusted_types_for_list
          build_source_list_directive(directive_name)
        when :boolean
          symbol_to_hyphen_case(directive_name) if @config.directive_value(directive_name)
        when :sandbox_list
          build_sandbox_list_directive(directive_name)
        when :media_type_list
          build_media_type_list_directive(directive_name)
        when :report_to_endpoint
          build_report_to_directive(directive_name)
        end
      end.compact.join("; ")
    end

    def build_sandbox_list_directive(directive)
      return unless sandbox_list = @config.directive_value(directive)
      max_strict_policy = case sandbox_list
      when Array
        sandbox_list.empty?
      when true
        true
      else
        false
      end

      # A maximally strict sandbox policy is just the `sandbox` directive,
      # with no configuration values.
      if max_strict_policy
        symbol_to_hyphen_case(directive)
      elsif sandbox_list && sandbox_list.any?
        [
          symbol_to_hyphen_case(directive),
          scrub_directive_value(directive, sandbox_list.uniq.join(" "))
        ].join(" ")
      end
    end

    def build_media_type_list_directive(directive)
      return unless media_type_list = @config.directive_value(directive)
      if media_type_list && media_type_list.any?
        [
          symbol_to_hyphen_case(directive),
          scrub_directive_value(directive, media_type_list.uniq.join(" "))
        ].join(" ")
      end
    end

    def build_report_to_directive(directive)
      return unless endpoint_name = @config.directive_value(directive)
      if endpoint_name && endpoint_name.is_a?(String) && !endpoint_name.empty?
        [symbol_to_hyphen_case(directive), scrub_directive_value(directive, endpoint_name)].join(" ")
      end
    end

    # Bytes that would let a caller-controlled value break out of its
    # directive and inject sibling CSP directives. CR/LF are included
    # so naive downstreams that split on bare \r can't be used to
    # smuggle directives either.
    DIRECTIVE_INJECTION_REGEX = /[\n\r;]/.freeze

    # Private: scrubs caller-controlled bytes that would let a value
    # break out of its CSP directive (CR, LF, semicolon). Shared across
    # every directive builder so sandbox / plugin-types / report-to /
    # source-list all reject the same byte set with the same warn UX.
    # Emits a single Kernel.warn per directive even when multiple
    # offending bytes are present.
    def scrub_directive_value(directive, value)
      str = value.to_s
      if str =~ DIRECTIVE_INJECTION_REGEX
        Kernel.warn("#{directive} contains a #{$~[0].inspect} in #{str.inspect} which will raise an error in future versions. It has been replaced with a blank space.")
        str.gsub(DIRECTIVE_INJECTION_REGEX, " ")
      else
        str
      end
    end

    # Private: builds a string that represents one directive in a minified form.
    #
    # directive_name - a symbol representing the various ALL_DIRECTIVES
    #
    # Returns a string representing a directive.
    def build_source_list_directive(directive)
      source_list = @config.directive_value(directive)
      if source_list != OPT_OUT && source_list && source_list.any?
        minified_source_list = minify_source_list(directive, source_list).join(" ")
        escaped_source_list = scrub_directive_value(directive, minified_source_list)
        [symbol_to_hyphen_case(directive), escaped_source_list].join(" ").strip
      end
    end

    # If a directive contains *, all other values are omitted.
    # If a directive contains 'none' but has other values, 'none' is omitted.
    # Schemes are stripped (see http://www.w3.org/TR/CSP2/#match-source-expression)
    def minify_source_list(directive, source_list)
      source_list = source_list.compact
      if source_list.include?(STAR)
        keep_wildcard_sources(source_list)
      else
        source_list = populate_nonces(directive, source_list)
        source_list = reject_all_values_if_none(source_list)
        source_list = normalize_uri_paths(source_list)

        unless directive == REPORT_URI || @preserve_schemes
          source_list = strip_source_schemes(source_list)
        end
        source_list.uniq
      end
    end

    # Discard trailing entries (excluding unsafe-*) since * accomplishes the same.
    def keep_wildcard_sources(source_list)
      source_list.select { |value| WILDCARD_SOURCES.include?(value) }
    end

    # Discard any 'none' values if more directives are supplied since none may override values.
    def reject_all_values_if_none(source_list)
      if source_list.length > 1
        source_list.reject { |value| value == NONE }
      else
        source_list
      end
    end

    def normalize_uri_paths(source_list)
      source_list.map do |source|
        # Normalize domains ending in a single / as without omitting the slash accomplishes the same.
        # https://www.w3.org/TR/CSP3/#match-paths § 6.6.2.10 Step 2
        begin
          uri = URI(source)
          if uri.path == "/"
            next source.chomp("/")
          end
        rescue URI::InvalidURIError
        end

        if source.chomp("/").include?("/")
          source
        else
          source.chomp("/")
        end
      end
    end

    # Private: append a nonce to the script/style directories if script_nonce
    # or style_nonce are provided.
    def populate_nonces(directive, source_list)
      case directive
      when SCRIPT_SRC
        append_nonce(source_list, @script_nonce)
      when STYLE_SRC
        append_nonce(source_list, @style_nonce)
      else
        source_list
      end
    end

    # Private: adds a nonce or 'unsafe-inline' depending on browser support.
    # If a nonce is populated, inline content is assumed.
    #
    # While CSP is backward compatible in that a policy with a nonce will ignore
    # unsafe-inline, this is more concise.
    def append_nonce(source_list, nonce)
      if nonce
        source_list.push("'nonce-#{nonce}'")
        source_list.push(UNSAFE_INLINE) unless @config[:disable_nonce_backwards_compatibility]
      end

      source_list
    end

    # Private: return the list of directives,
    # starting with default-src and ending with reporting directives (alphabetically ordered).
    def directives
      [
        DEFAULT_SRC,
        BODY_DIRECTIVES,
        REPORT_TO,
        REPORT_URI,
      ].flatten
    end

    # Private: Remove scheme from source expressions.
    def strip_source_schemes(source_list)
      source_list.map { |source_expression| source_expression.sub(HTTP_SCHEME_REGEX, "") }
    end

    def symbol_to_hyphen_case(sym)
      sym.to_s.tr("_", "-")
    end
  end
end
