# frozen_string_literal: true require "content_security_policy/default" class ContentSecurityPolicy class Builder EXTENDABLE_DIRECTIVES = %i[ base_uri frame_ancestors manifest_src object_src script_src worker_src ].freeze # Make extending these directives no-op, until core includes them in default CSP TO_BE_EXTENDABLE = %i[ connect_src default_src font_src form_action frame_src img_src media_src prefetch_src style_src ].freeze def initialize(base_url:, env: {}) @directives = Default.new(base_url: base_url, env: env).directives @base_url = base_url end def <<(extension) return unless valid_extension?(extension) extension.each do |directive, sources| extend_directive(normalize_directive(directive), sources) end end def build policy = ActionDispatch::ContentSecurityPolicy.new @directives.each do |directive, sources| if sources.is_a?(Array) policy.public_send(directive, *sources) else policy.public_send(directive, sources) end end policy.build end private def normalize_directive(directive) directive.to_s.gsub("-", "_").to_sym end def normalize_source(source) if source.starts_with?("/") "#{@base_url}#{source}" else source end rescue URI::ParseError source end def extend_directive(directive, sources) return unless extendable?(directive) @directives[directive] ||= [] sources = Array(sources).map { |s| normalize_source(s) } @directives[directive].concat(sources) @directives[directive].delete(:none) if @directives[directive].count > 1 end def extendable?(directive) EXTENDABLE_DIRECTIVES.include?(directive) end def valid_extension?(extension) extension.is_a?(Hash) end end end