Skip to content

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 →