Security Headers Analyzer — Ruby source
Paste HTTP response headers to analyze security posture. Checks CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Security Headers — HTTP response-header analysis and grading.
#
# Language: Ruby (3.x, standard library only — the whole analyzer is pure
# string work, so it needs no requires at all)
# Source: CosmoDev polyglot showcase port of the Security Headers Analyzer
# tool, ported from src/lib/security-headers.ts (the canonical
# TypeScript implementation).
# License: display source — part of CosmoDev's polyglot tool pages.
#
# Parses a raw response-header block (the "key: value" lines copied from
# DevTools' Network panel), grades each of 8 security headers A/B/C/F, and rolls
# a weighted overall score. Header names match case-insensitively, folded
# (indented) continuation lines join their parent value, and duplicate names
# join with ", ". Malformed lines and the HTTP status line are ignored. Nothing
# here ever raises — a pasted header block is untrusted input and every branch
# has a defined verdict.
module SecurityHeaders
# One security header as analyzed: the parsed value plus its grade and
# guidance. `value` is nil when the header is absent. Grades are 'A' (best
# practice), 'B' (present but weak), 'C' (missing) and 'F' (actively harmful).
ParsedHeader = Struct.new(:name, :value, :grade, :explanation, :recommendation,
keyword_init: true)
# The full analysis: per-header rows, the overall grade, and the fix list.
HeaderAnalysis = Struct.new(:headers, :grade, :score, :recommendations,
keyword_init: true)
# Catalog entry: canonical name, why the header matters, and its score weight.
HeaderSpec = Struct.new(:name, :description, :weight, keyword_init: true)
# The verdict fields every grader returns; folded into ParsedHeader.
Grading = Struct.new(:grade, :explanation, :recommendation, keyword_init: true)
# The 8 security headers this analyzer grades, in display order.
SECURITY_HEADERS = [
HeaderSpec.new(
name: 'Strict-Transport-Security', weight: 1.5,
description: "HSTS tells the browser 'only ever reach this site over HTTPS' for max-age " \
'seconds. It defeats SSL-strip attacks, which downgrade the very first ' \
'plain-HTTP visit. includeSubDomains extends the guarantee to every ' \
'subdomain; preload lets the site join the browser-built HSTS preload list ' \
'so even the first visit is HTTPS.'
),
HeaderSpec.new(
name: 'Content-Security-Policy', weight: 2,
description: 'CSP is the strongest anti-XSS control available in a header: it declares ' \
'which sources scripts, styles, and other resources may load from. ' \
"'unsafe-inline' and 'unsafe-eval' punch holes straight through it - they " \
're-allow inline injection and string-to-code evaluation. A default-src ' \
'directive is the baseline so resource types you forgot to list inherit a ' \
"restrictive fallback instead of the browser's permissive one."
),
HeaderSpec.new(
name: 'X-Frame-Options', weight: 1,
description: 'Stops other sites from embedding this page in an iframe, which is the ' \
'basis of clickjacking: an attacker overlays invisible UI on your framed ' \
"page and harvests the victim's clicks. DENY blocks all framing; " \
'SAMEORIGIN allows only your own origin. The modern replacement is the CSP ' \
'frame-ancestors directive.'
),
HeaderSpec.new(
name: 'X-Content-Type-Options', weight: 1,
description: 'With the value nosniff, the browser must respect the declared Content-Type ' \
"instead of 'helpfully' sniffing the payload. Sniffing turns an uploaded " \
'text file into executable JavaScript when its bytes look script-shaped - ' \
'the classic content-type-confusion attack.'
),
HeaderSpec.new(
name: 'Referrer-Policy', weight: 1,
description: 'Controls how much of the URL the browser leaks in the Referer header when ' \
'the user navigates to another site. Full URLs can carry IDs, tokens, or ' \
'search queries to third parties. strict-origin-when-cross-origin sends ' \
'only the origin cross-origin (the browser default since 2020); no-referrer ' \
'and same-origin leak even less. unsafe-url sends the full URL everywhere, ' \
'including on plain-HTTP requests.'
),
HeaderSpec.new(
name: 'Permissions-Policy', weight: 1,
description: 'Declares which browser features (camera, microphone, geolocation, payment, ' \
'USB...) the page and its embedded iframes may use. An empty allowlist - ' \
'feature=() - switches the feature off entirely; an =* wildcard re-allows ' \
'it everywhere, defeating the point of declaring it.'
),
HeaderSpec.new(
name: 'X-XSS-Protection', weight: 0.5,
description: 'The 2010-era XSS Auditor: browsers inspected reflected input in the page ' \
'and blocked obviously injected scripts. Chrome removed it in 2019 (its ' \
'heuristics introduced cross-site leaks of their own) and every modern ' \
'browser now ignores it. The header is deprecated - CSP is the real ' \
"defense. Sites either omit it or send '0' to switch old browsers off."
),
HeaderSpec.new(
name: 'Cross-Origin-Opener-Policy', weight: 1,
description: "Isolates the page's window handle from other origins. Without COOP, a site " \
'that opened your page in a popup (or was opened by it) keeps a JS ' \
'reference to it, enabling some cross-window attacks and Spectre-class side ' \
'channels. same-origin cuts that shared handle; unsafe-none restores the ' \
'old shared browsing context.'
)
].freeze
# Grade points used for the weighted overall score.
GRADE_POINTS = { 'A' => 100, 'B' => 70, 'C' => 40, 'F' => 0 }.freeze
# One year in seconds - the minimum HSTS max-age worth calling strong.
ONE_YEAR = 31_536_000
REFERRER_STRICT = %w[no-referrer same-origin strict-origin
strict-origin-when-cross-origin].freeze
REFERRER_WEAK = %w[origin origin-when-cross-origin no-referrer-when-downgrade].freeze
module_function
# Parse a raw header block into a lowercased-name hash. Lines are
# `name: value`; the `HTTP/1.1 200 OK` status line and any line without a
# colon are ignored. A line starting with space/tab continues the previous
# header (RFC 7230 obs-fold); a repeated name joins its values with ", ".
def parse_headers(raw)
headers = {}
last_name = nil
raw.to_s.split(/\r?\n/, -1).each do |line|
if /\A\s/.match?(line)
# Folded continuation - append to the previous header, if any.
unless last_name.nil?
headers[last_name] = "#{headers.fetch(last_name, '')} #{line.strip}".strip
end
next
end
colon = line.index(':')
next if colon.nil? # status line, blank line, or junk
name = line[0, colon].strip.downcase
next if name.empty?
value = line[(colon + 1)..].to_s.strip
headers[name] = headers.key?(name) ? "#{headers[name]}, #{value}" : value
last_name = name
end
headers
end
# Analyze a raw HTTP response-header block. Every catalog header gets a
# ParsedHeader row (in catalog order); the overall score is the
# weight-adjusted average of per-header grade points, and recommendations
# list one fix per header that is not at grade A.
def analyze_headers(raw)
parsed = parse_headers(raw)
headers = SECURITY_HEADERS.map do |spec|
grading = grade_for(spec.name, parsed[spec.name.downcase])
ParsedHeader.new(name: spec.name, value: parsed[spec.name.downcase],
grade: grading.grade, explanation: grading.explanation,
recommendation: grading.recommendation)
end
total_weight = SECURITY_HEADERS.sum(&:weight)
points = headers.each_with_index.sum do |header, i|
GRADE_POINTS.fetch(header.grade) * SECURITY_HEADERS[i].weight
end
score = (points / total_weight).round
HeaderAnalysis.new(headers: headers, grade: overall_grade(score), score: score,
recommendations: headers.reject { |h| h.grade == 'A' }
.map(&:recommendation))
end
# Dispatch to the grader for a catalog header. Every catalog name has one.
def grade_for(name, value)
case name
when 'Strict-Transport-Security' then grade_hsts(value)
when 'Content-Security-Policy' then grade_csp(value)
when 'X-Frame-Options' then grade_frame_options(value)
when 'X-Content-Type-Options' then grade_content_type_options(value)
when 'Referrer-Policy' then grade_referrer_policy(value)
when 'Permissions-Policy' then grade_permissions_policy(value)
when 'X-XSS-Protection' then grade_xss_protection(value)
when 'Cross-Origin-Opener-Policy' then grade_coop(value)
else raise ArgumentError, "No grader for #{name}"
end
end
# Map a weighted 0-100 score to the overall A/B/C/F grade.
def overall_grade(score)
return 'A' if score >= 90
return 'B' if score >= 70
return 'C' if score >= 45
'F'
end
# --- per-header graders: value (nil when absent) -> grade + guidance -------
def grade_hsts(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - the browser accepts plain HTTP, so the first visit (and ' \
'SSL-strip attacks) can downgrade the connection.',
recommendation: 'Add: strict-transport-security: max-age=31536000; includeSubDomains; preload'
)
end
max_age_match = /max-age\s*=\s*(\d+)/i.match(value)
if max_age_match.nil?
return Grading.new(
grade: 'B',
explanation: "Present but malformed - no readable max-age directive (#{value}).",
recommendation: 'Use: strict-transport-security: max-age=31536000; includeSubDomains'
)
end
max_age = max_age_match[1].to_i
if max_age.zero?
return Grading.new(
grade: 'F',
explanation: 'max-age=0 actively deletes the HSTS policy - the site opts out of ' \
'HTTPS-only enforcement.',
recommendation: 'Raise max-age to at least 31536000 (one year).'
)
end
has_subs = /;\s*includesubdomains/i.match?(value)
if max_age < ONE_YEAR
return Grading.new(
grade: 'B',
explanation: "Present with max-age=#{max_age}s (< 1 year)" \
"#{has_subs ? ' and includeSubDomains' : ''} - a shorter window weakens " \
'the guarantee.',
recommendation: 'Raise max-age to 31536000 (one year) or more.'
)
end
unless has_subs
return Grading.new(
grade: 'B',
explanation: 'Strong max-age (>= 1 year), but includeSubDomains is missing - ' \
'subdomains stay downgradable.',
recommendation: 'Append ; includeSubDomains once every subdomain serves HTTPS.'
)
end
has_preload = /;\s*preload/i.match?(value)
Grading.new(
grade: 'A',
explanation: 'Strong: max-age >= 1 year + includeSubDomains' \
"#{has_preload ? ' + preload - eligible for the browser preload list.' : '. Add preload to join the browser preload list.'}",
recommendation: 'Best practice met - nothing to change.'
)
end
def grade_csp(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - no restriction on where scripts and resources load from, so ' \
'injected markup runs at full power.',
recommendation: "Add a policy, e.g.: content-security-policy: default-src 'none'; " \
"script-src 'self'; ..."
)
end
inline = /'unsafe-inline'/i.match?(value)
evaluates = /'unsafe-eval'/i.match?(value)
if inline && evaluates
return Grading.new(
grade: 'F',
explanation: "Actively harmful - 'unsafe-inline' + 'unsafe-eval' together re-allow " \
'inline injection and string evaluation, gutting the policy while it ' \
'still looks present.',
recommendation: "Remove 'unsafe-inline' and 'unsafe-eval'; use nonces or hashes for " \
'inline scripts.'
)
end
if inline || evaluates
culprit = inline ? "'unsafe-inline'" : "'unsafe-eval'"
return Grading.new(
grade: 'B',
explanation: "Present but weak - #{culprit} punches a hole through the anti-XSS " \
'guarantee.',
recommendation: "Remove #{culprit}; use nonces or hashes for legitimate inline code."
)
end
unless /(\A|\s)default-src\s/i.match?(value)
return Grading.new(
grade: 'B',
explanation: 'Present with no default-src - resource types not listed explicitly fall ' \
'back to the browser permissive default.',
recommendation: "Start the policy with default-src 'none' or 'self', then allow what " \
'you need.'
)
end
Grading.new(
grade: 'A',
explanation: 'Strong - a default-src fallback with no unsafe-inline / unsafe-eval ' \
'escape hatches.',
recommendation: 'Best practice met - nothing to change.'
)
end
def grade_frame_options(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - any site can embed this page in an iframe and overlay it ' \
'(clickjacking).',
recommendation: 'Add: x-frame-options: DENY (or SAMEORIGIN if you embed yourself)'
)
end
v = value.strip.upcase
if %w[DENY SAMEORIGIN].include?(v)
return Grading.new(
grade: 'A',
explanation: "Strong - #{v} blocks framing by other origins.",
recommendation: 'Best practice met - nothing to change.'
)
end
if v.start_with?('ALLOW-FROM')
return Grading.new(
grade: 'B',
explanation: 'ALLOW-FROM is deprecated and modern browsers ignore it - the page is ' \
'effectively frameable.',
recommendation: 'Replace with a CSP frame-ancestors directive (x-frame-options: DENY ' \
'as fallback).'
)
end
Grading.new(
grade: 'B',
explanation: "Unrecognized value (#{value}) - browsers ignore it, leaving the page " \
'frameable.',
recommendation: 'Use DENY or SAMEORIGIN.'
)
end
def grade_content_type_options(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - browsers may sniff uploaded content into executable types.',
recommendation: 'Add: x-content-type-options: nosniff'
)
end
if value.strip.downcase == 'nosniff'
return Grading.new(
grade: 'A',
explanation: 'Strong - MIME sniffing is off; the declared Content-Type is final.',
recommendation: 'Best practice met - nothing to change.'
)
end
Grading.new(
grade: 'B',
explanation: "Present but the value (#{value}) is not nosniff, so browsers ignore the " \
'header.',
recommendation: 'Send exactly: x-content-type-options: nosniff'
)
end
def grade_referrer_policy(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - older browsers default to leaking the full URL to whatever ' \
'site the user navigates to.',
recommendation: 'Add: referrer-policy: strict-origin-when-cross-origin (or no-referrer)'
)
end
v = value.strip.downcase
if v == 'unsafe-url'
return Grading.new(
grade: 'F',
explanation: 'Actively harmful - unsafe-url sends the full URL (including IDs and ' \
'tokens in query strings) to every destination, even on plain HTTP.',
recommendation: 'Use strict-origin-when-cross-origin or no-referrer instead.'
)
end
if REFERRER_STRICT.include?(v)
return Grading.new(
grade: 'A',
explanation: "Strong - #{v} leaks at most the origin cross-origin.",
recommendation: 'Best practice met - nothing to change.'
)
end
if REFERRER_WEAK.include?(v)
return Grading.new(
grade: 'B',
explanation: "Present but leaky - #{v} still shares the full URL in some same- or " \
'cross-origin cases.',
recommendation: 'Tighten to strict-origin-when-cross-origin, strict-origin, ' \
'same-origin, or no-referrer.'
)
end
Grading.new(
grade: 'B',
explanation: "Unrecognized value (#{value}) - browsers fall back to their default policy.",
recommendation: 'Use one of the standard directives, e.g. strict-origin-when-cross-origin.'
)
end
def grade_permissions_policy(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - powerful features (camera, geolocation, payment...) default ' \
'to the browser policy, not yours.',
recommendation: 'Add: permissions-policy: camera=(), microphone=(), geolocation=()'
)
end
v = value.gsub(/\s+/, '').downcase
if v.empty?
return Grading.new(
grade: 'B',
explanation: 'Present but empty - no feature is restricted, so it declares nothing.',
recommendation: 'List the features to switch off, e.g. camera=(), microphone=().'
)
end
if v == '*' || v.include?('=*')
return Grading.new(
grade: 'B',
explanation: 'Present but permissive - a =* wildcard re-allows the listed feature(s) ' \
'everywhere.',
recommendation: 'Use an empty allowlist () or a specific origin instead of *.'
)
end
Grading.new(
grade: 'A',
explanation: 'Strong - the policy restricts at least one powerful feature.',
recommendation: 'Best practice met - nothing to change.'
)
end
def grade_xss_protection(value)
if value.nil?
return Grading.new(
grade: 'A',
explanation: 'Correctly omitted - the header is deprecated; modern browsers removed ' \
'the XSS Auditor it drove.',
recommendation: 'Nothing to change - rely on Content-Security-Policy instead.'
)
end
v = value.strip
if v == '0' || v.start_with?('0;')
return Grading.new(
grade: 'A',
explanation: "Explicitly disabled ('0') - the right call for a deprecated header old " \
'browsers still honor.',
recommendation: 'Nothing to change - or remove the header entirely.'
)
end
if v.start_with?('1')
return Grading.new(
grade: 'B',
explanation: "Deprecated - '1; mode=block' is ignored by modern browsers and the old " \
'Auditor had cross-site leaks of its own.',
recommendation: "Send '0' or drop the header; use Content-Security-Policy for XSS " \
'defense.'
)
end
Grading.new(
grade: 'B',
explanation: "Present with an unrecognized value (#{value}) - remove the deprecated header.",
recommendation: "Send '0' or drop the header entirely."
)
end
def grade_coop(value)
if value.nil?
return Grading.new(
grade: 'C',
explanation: 'Missing - pages that open or are opened by other origins share a ' \
'browsing context with them.',
recommendation: 'Add: cross-origin-opener-policy: same-origin'
)
end
v = value.strip.downcase
if v == 'same-origin'
return Grading.new(
grade: 'A',
explanation: 'Strong - the window handle is isolated from cross-origin openers.',
recommendation: 'Best practice met - nothing to change.'
)
end
if v == 'same-origin-allow-popups'
return Grading.new(
grade: 'B',
explanation: 'Partially isolated - popups you open keep a reference to this window.',
recommendation: 'Use same-origin unless you genuinely need cross-origin popup handles.'
)
end
if v == 'unsafe-none'
return Grading.new(
grade: 'B',
explanation: 'Explicit opt-out - unsafe-none restores the shared browsing context ' \
'COOP exists to cut.',
recommendation: 'Use same-origin unless a documented integration requires shared handles.'
)
end
Grading.new(
grade: 'B',
explanation: "Unrecognized value (#{value}) - browsers treat it as no COOP.",
recommendation: 'Use same-origin.'
)
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 →