Skip to content

CSP Builder — Ruby source

Build a Content-Security-Policy header interactively. Toggle directives, add sources, see the assembled header in real time — with a security score that flags unsafe sources.

This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.

# CSP Builder — pure Content-Security-Policy logic, deterministic.
#
# Language: Ruby (3.1+, standard library only)
# Source:   CosmoDev polyglot showcase port of the CSP Builder tool, ported
#           from src/lib/csp-builder.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# A CSP is modeled as a map of directive -> source list. Build assembles the
# map into the header string (directives in catalog order, then any unknown
# directives in insertion order); parse reads a header back into the map.
# Neither function ever raises - parse is lenient by design so a pasted
# real-world header always yields something editable.

module CSPBuilder
  # One entry of the built-in directive catalog.
  DirectiveInfo = Struct.new(:name, :kind, :description, :risk,
                             :default_sources, keyword_init: true)

  # A policy: directive name (lowercase) -> enabled source list. Present
  # key = enabled.
  # A policy problem: either policy-wide (directive === '') or a risky
  # source.
  CspIssue = Struct.new(:directive, :source, :message, keyword_init: true)

  # The catalog, in canonical build/display order.
  CSP_DIRECTIVES = [
    DirectiveInfo.new(
      name: 'default-src', kind: 'sources', risk: 'medium',
      description: 'Fallback for every fetch directive you do not set explicitly. ' \
                   'Set this first, then tighten individual directives.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'script-src', kind: 'sources', risk: 'high',
      description: 'Where scripts may load from. The single most important XSS ' \
                   "control - keep it as tight as you can.",
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'style-src', kind: 'sources', risk: 'medium',
      description: 'Where stylesheets may load from. Also gates inline style attributes.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'img-src', kind: 'sources', risk: 'low',
      description: 'Where images and favicons may load from.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'connect-src', kind: 'sources', risk: 'medium',
      description: 'Which URLs scripts may connect to (fetch, XHR, WebSocket). ' \
                   'Your data-exfiltration boundary.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'font-src', kind: 'sources', risk: 'low',
      description: 'Where web fonts may load from.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'frame-src', kind: 'sources', risk: 'low',
      description: 'Which URLs may be embedded as child browsing contexts (iframe, frame).',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'media-src', kind: 'sources', risk: 'low',
      description: 'Where audio and video may load from.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'object-src', kind: 'sources', risk: 'high',
      description: "Where plugin content (object, embed, applet) may load from. " \
                   "Almost always should be 'none'.",
      default_sources: ["'none'"]
    ),
    DirectiveInfo.new(
      name: 'base-uri', kind: 'sources', risk: 'high',
      description: "Which URLs may set the document base. Restrict to 'self' to " \
                   'block <base> hijacking of relative URLs.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'form-action', kind: 'sources', risk: 'medium',
      description: 'Where forms may submit to. Does not fall back to default-src.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'frame-ancestors', kind: 'sources', risk: 'medium',
      description: 'Which parents may embed this page (clickjacking control). ' \
                   'Ignored inside a <meta> tag - header delivery only.',
      default_sources: ["'self'"]
    ),
    DirectiveInfo.new(
      name: 'report-uri', kind: 'url', risk: 'low',
      description: 'URL where the browser posts violation reports. Pair with a report collector.',
      default_sources: []
    ),
    DirectiveInfo.new(
      name: 'upgrade-insecure-requests', kind: 'flag', risk: 'low',
      description: 'Tells the browser to rewrite http:// subresource requests to https://.',
      default_sources: []
    ),
    DirectiveInfo.new(
      name: 'block-all-mixed-content', kind: 'flag', risk: 'low',
      description: 'Blocks loading of any http:// subresource on an https:// page.',
      default_sources: []
    )
  ].freeze

  # Source presets offered in the UI when adding a source to a directive.
  COMMON_SOURCES = [
    "'self'",
    "'none'",
    "'unsafe-inline'",
    "'unsafe-eval'",
    "'strict-dynamic'",
    'data:',
    'blob:',
    'https:'
  ].freeze

  # Directives that take no value - emitted as a bare name.
  FLAG_DIRECTIVES = CSP_DIRECTIVES.select { |d| d.kind == 'flag' }.map(&:name).freeze

  # Catalog names, for ordering during build.
  KNOWN_DIRECTIVES = CSP_DIRECTIVES.map(&:name).freeze

  # Sources treated as security-weakening, compared case-insensitively.
  RISKY_SOURCES = ["'unsafe-inline'", "'unsafe-eval'", 'data:', 'http:', '*'].freeze

  # Short human explanation for each risky source (tooltip text in the UI).
  RISK_EXPLANATIONS = {
    "'unsafe-inline'" => 'Allows inline <script>/<style> and event handlers - ' \
                         "defeats most of CSP's XSS protection.",
    "'unsafe-eval'" => 'Allows eval() and similar code execution - weakens XSS protection.',
    '*' => 'Allows every origin - effectively no restriction for this directive.',
    'data:' => 'data: URIs can carry arbitrary payloads and are same-origin - ' \
               'attackers can smuggle content through them.',
    'http:' => 'Allows insecure origins - a network attacker can inject or ' \
               'tamper with subresources.'
  }.freeze

  # Score penalty per risky source (case-insensitive key).
  SCORE_PENALTIES = {
    "'unsafe-inline'" => 20,
    "'unsafe-eval'" => 15,
    '*' => 20,
    'data:' => 10,
    'http:' => 10
  }.freeze

  class << self
    # Assemble a policy map into the `Content-Security-Policy` header value.
    # Known directives emit in catalog order, unknown directives after them
    # in insertion order. Flag directives emit as a bare name; source/url
    # directives with an empty list are omitted (a valueless directive is
    # invalid CSP). An empty map yields an empty string.
    def build_csp(directives)
      parts = []
      emit = lambda do |name|
        sources = directives[name]
        next if sources.nil?
        if FLAG_DIRECTIVES.include?(name)
          parts << name
          next
        end
        next if sources.empty?

        parts << "#{name} #{sources.join(' ')}"
      end
      CSP_DIRECTIVES.each { |d| emit.call(d.name) }
      directives.each_key { |name| emit.call(name) unless KNOWN_DIRECTIVES.include?(name) }
      parts.join('; ')
    end

    # Parse a CSP header value back into a policy map. Lenient: splits on
    # semicolons and whitespace, lowercases directive names, ignores empty
    # tokens, and strips an optional leading `Content-Security-Policy:` label
    # so a pasted full header line works. Duplicate directives keep only the
    # first occurrence (matching how browsers honor them). Never raises;
    # garbage in, {} out.
    def parse_csp(header)
      text = header.strip
      if text.match?(/\Acontent-security-policy\s*:/i)
        text = text[(text.index(':') + 1)..]
      end
      out = {}
      text.split(';').each do |token|
        words = token.strip.split(/\s+/).reject(&:empty?)
        next if words.empty?

        name = words[0].downcase
        next if out.key?(name)

        out[name] = words[1..]
      end
      out
    end

    # True when a source weakens the policy: 'unsafe-inline', 'unsafe-eval',
    # 'data:', 'http:', the bare wildcard '*', or any insecure http:// URL.
    # 'self', 'none', 'strict-dynamic', 'blob:', 'https:' and https URLs are
    # fine.
    def is_risky_source(source)
      s = source.strip.downcase
      RISKY_SOURCES.include?(s) || s.start_with?('http://')
    end

    # Explanation for any risky source; falls back to the generic
    # insecure-origin text.
    def risk_explanation(source)
      key = source.strip.downcase
      RISK_EXPLANATIONS[key] ||
        'Insecure http:// URL - traffic can be tampered with in transit.'
    end

    # Lint a policy: warns when default-src is missing (unset directives fall
    # back to the browser's allow-everything default) and flags every risky
    # source.
    def validate_csp(directives)
      issues = []
      unless directives.key?('default-src')
        issues << CspIssue.new(
          directive: '',
          source: nil,
          message: "No default-src - every directive you don't set explicitly " \
                   "falls back to the browser's permissive default."
        )
      end
      directives.each do |name, sources|
        sources.each do |src|
          next unless is_risky_source(src)

          issues << CspIssue.new(
            directive: name,
            source: src,
            message: "#{name}: #{src} weakens this policy - #{risk_explanation(src)}"
          )
        end
      end
      issues
    end

    # Security score, 0-100. Starts at 100; each risky source subtracts its
    # penalty (insecure http:// URLs subtract 10), and a missing default-src
    # subtracts 10. Clamped to 0-100. Deterministic.
    def security_score(directives)
      score = 100
      score -= 10 unless directives.key?('default-src')
      directives.each_value do |sources|
        sources.each do |src|
          s = src.strip.downcase
          score -= SCORE_PENALTIES.fetch(s, s.start_with?('http://') ? 10 : 0)
        end
      end
      score.clamp(0, 100)
    end
  end
end

Also available in 8 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →