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 →