Security Headers Analyzer — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! security-headers — HTTP security-header grading, no dependencies,
//! deterministic.
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/security-headers.ts (the canonical TypeScript implementation).
//! 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.
//! Never fails on input shape.
const std = @import("std");
/// Per-header grade: A best practice, B present but weak, C missing or
/// dangerous, F actively harmful.
pub const HeaderGrade = enum { A, B, C, F };
/// One security header as analyzed: the parsed value plus its grade and guidance.
pub const ParsedHeader = struct {
name: []const u8, // canonical name, e.g. "Strict-Transport-Security"
value: ?[]const u8, // raw value as parsed; null when the header is absent
grade: HeaderGrade,
explanation: []const u8,
recommendation: []const u8,
};
/// The full analysis result: per-header rows, the overall grade, and fix list.
pub const HeaderAnalysis = struct {
headers: []ParsedHeader,
grade: HeaderGrade,
score: i64, // 0-100 weighted average of per-header grades
recommendations: [][]const u8,
};
/// Catalog entry: canonical name, why the header matters, and its score weight.
pub const HeaderSpec = struct {
name: []const u8,
description: []const u8,
weight: f64,
};
/// The 8 security headers this analyzer grades, in display order.
pub const SECURITY_HEADERS = [_]HeaderSpec{
.{
.name = "Strict-Transport-Security",
.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.",
.weight = 1.5,
},
.{
.name = "Content-Security-Policy",
.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.",
.weight = 2,
},
.{
.name = "X-Frame-Options",
.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.",
.weight = 1,
},
.{
.name = "X-Content-Type-Options",
.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.",
.weight = 1,
},
.{
.name = "Referrer-Policy",
.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.",
.weight = 1,
},
.{
.name = "Permissions-Policy",
.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.",
.weight = 1,
},
.{
.name = "X-XSS-Protection",
.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.",
.weight = 0.5,
},
.{
.name = "Cross-Origin-Opener-Policy",
.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.",
.weight = 1,
},
};
/// Grade points used for the weighted overall score.
fn gradePoints(grade: HeaderGrade) f64 {
return switch (grade) {
.A => 100,
.B => 70,
.C => 40,
.F => 0,
};
}
/// One year in seconds - the minimum HSTS max-age worth calling strong.
const ONE_YEAR = 31536000;
// --- ASCII helpers (header names/values are ASCII in practice) ---------------
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
fn indexOfIgnoreCase(haystack: []const u8, needle: []const u8) ?usize {
if (needle.len == 0 or haystack.len < needle.len) return null;
var i: usize = 0;
while (i + needle.len <= haystack.len) : (i += 1) {
if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return i;
}
return null;
}
fn startsWithIgnoreCase(haystack: []const u8, prefix: []const u8) bool {
return haystack.len >= prefix.len and std.ascii.eqlIgnoreCase(haystack[0..prefix.len], prefix);
}
fn trim(s: []const u8) []const u8 {
return std.mem.trim(u8, s, " \t\r\n");
}
fn lowerInto(buf: []u8, s: []const u8) []const u8 {
const n = @min(buf.len, s.len);
for (s[0..n], 0..) |c, i| buf[i] = std.ascii.toLower(c);
return buf[0..n];
}
// --- Header-block parsing ------------------------------------------------------
/// Parse a raw header block into a lowercased-name map. 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 ", ". Caller owns the map
/// (keys and values are allocated with `allocator`).
pub fn parseHeaders(allocator: std.mem.Allocator, raw: []const u8) !std.StringHashMap([]const u8) {
var headers = std.StringHashMap([]const u8).init(allocator);
errdefer headers.deinit();
var last_name: ?[]const u8 = null;
var name_buf: [256]u8 = undefined; // header names are far shorter than this
var lines = std.mem.splitScalar(u8, raw, '\n');
while (lines.next()) |line_raw| {
const line = std.mem.trimRight(u8, line_raw, "\r");
if (line.len > 0 and (line[0] == ' ' or line[0] == '\t')) {
// Folded continuation - append to the previous header, if any.
if (last_name) |name| {
const prev = headers.get(name) orelse "";
const joined = try std.fmt.allocPrint(allocator, "{s} {s}", .{ prev, trim(line) });
try headers.put(name, joined);
}
continue;
}
const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; // status line, blank, or junk
const name = try allocator.dupe(u8, lowerInto(&name_buf, trim(line[0..colon])));
if (name.len == 0) continue;
const value = trim(line[colon + 1 ..]);
if (headers.get(name)) |prev| {
const joined = try std.fmt.allocPrint(allocator, "{s}, {s}", .{ prev, value });
try headers.put(name, joined);
} else {
try headers.put(name, try allocator.dupe(u8, value));
}
last_name = name;
}
return headers;
}
// --- Per-header graders ---------------------------------------------------------
const Grading = struct {
grade: HeaderGrade,
explanation: []const u8,
recommendation: []const u8,
};
/// Case-insensitive `max-age\s*=\s*(\d+)` — returns the parsed seconds.
fn hstsMaxAge(value: []const u8) ?u64 {
const at = indexOfIgnoreCase(value, "max-age") orelse return null;
var i = at + "max-age".len;
while (i < value.len and (value[i] == ' ' or value[i] == '\t')) i += 1;
if (i >= value.len or value[i] != '=') return null;
i += 1;
while (i < value.len and (value[i] == ' ' or value[i] == '\t')) i += 1;
const digits_start = i;
while (i < value.len and std.ascii.isDigit(value[i])) i += 1;
if (i == digits_start) return null;
return std.fmt.parseInt(u64, value[digits_start..i], 10) catch null;
}
/// `;\s*includesubdomains` / `;\s*preload` — a semicolon, optional spaces,
/// then the keyword.
fn hasDirective(value: []const u8, keyword: []const u8) bool {
var i: usize = 0;
while (i < value.len) : (i += 1) {
if (value[i] != ';') continue;
i += 1;
while (i < value.len and (value[i] == ' ' or value[i] == '\t')) i += 1;
if (value.len - i >= keyword.len and
std.ascii.eqlIgnoreCase(value[i .. i + keyword.len], keyword)) return true;
}
return false;
}
fn gradeHsts(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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",
};
const max_age = hstsMaxAge(v) orelse return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present but malformed - no readable max-age directive ({s}).", .{v}),
.recommendation = "Use: strict-transport-security: max-age=31536000; includeSubDomains",
};
if (max_age == 0) return .{
.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).",
};
const has_subs = hasDirective(v, "includesubdomains");
if (max_age < ONE_YEAR) return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present with max-age={d}s (< 1 year){s} - a shorter window weakens the guarantee.", .{ max_age, if (has_subs) " and includeSubDomains" else "" }),
.recommendation = "Raise max-age to 31536000 (one year) or more.",
};
if (!has_subs) return .{
.grade = .B,
.explanation = "Strong max-age (>= 1 year), but includeSubDomains is missing - subdomains stay downgradable.",
.recommendation = "Append ; includeSubDomains once every subdomain serves HTTPS.",
};
const has_preload = hasDirective(v, "preload");
return .{
.grade = .A,
.explanation = try std.fmt.allocPrint(allocator, "Strong: max-age >= 1 year + includeSubDomains{s}.", .{if (has_preload) " + preload - eligible for the browser preload list." else " Add preload to join the browser preload list."}),
.recommendation = "Best practice met - nothing to change.",
};
}
/// `(^|\s)default-src\s` — a token that starts a directive list.
fn hasDefaultSrc(value_lower: []const u8) bool {
const needle = "default-src";
var i: usize = 0;
while (indexOfIgnoreCase(value_lower[i..], needle)) |at| {
const abs = i + at;
const before_ok = abs == 0 or value_lower[abs - 1] == ' ' or value_lower[abs - 1] == '\t';
const after = abs + needle.len;
const after_ok = after < value_lower.len and (value_lower[after] == ' ' or value_lower[after] == '\t');
if (before_ok and after_ok) return true;
i = abs + needle.len;
}
return false;
}
fn gradeCsp(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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'; ...",
};
const inline = indexOfIgnoreCase(v, "'unsafe-inline'") != null;
const evaluates = indexOfIgnoreCase(v, "'unsafe-eval'") != null;
if (inline and evaluates) return .{
.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.",
};
if (inline or evaluates) {
const culprit = if (inline) "'unsafe-inline'" else "'unsafe-eval'";
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present but weak - {s} punches a hole through the anti-XSS guarantee.", .{culprit}),
.recommendation = try std.fmt.allocPrint(allocator, "Remove {s}; use nonces or hashes for legitimate inline code.", .{culprit}),
};
}
var lower_buf: [4096]u8 = undefined;
const lower = lowerInto(&lower_buf, v);
if (!hasDefaultSrc(lower)) return .{
.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.",
};
return .{
.grade = .A,
.explanation = "Strong - a default-src fallback with no unsafe-inline / unsafe-eval escape hatches.",
.recommendation = "Best practice met - nothing to change.",
};
}
fn gradeFrameOptions(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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)",
};
var upper_buf: [128]u8 = undefined;
const upper = blk: {
const t = trim(v);
const n = @min(upper_buf.len, t.len);
for (t[0..n], 0..) |c, i| upper_buf[i] = std.ascii.toUpper(c);
break :blk upper_buf[0..n];
};
if (eqlIgnoreCase(upper, "DENY") or eqlIgnoreCase(upper, "SAMEORIGIN")) return .{
.grade = .A,
.explanation = try std.fmt.allocPrint(allocator, "Strong - {s} blocks framing by other origins.", .{upper}),
.recommendation = "Best practice met - nothing to change.",
};
if (startsWithIgnoreCase(upper, "ALLOW-FROM")) return .{
.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).",
};
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Unrecognized value ({s}) - browsers ignore it, leaving the page frameable.", .{v}),
.recommendation = "Use DENY or SAMEORIGIN.",
};
}
fn gradeContentTypeOptions(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.grade = .C,
.explanation = "Missing - browsers may sniff uploaded content into executable types.",
.recommendation = "Add: x-content-type-options: nosniff",
};
if (eqlIgnoreCase(trim(v), "nosniff")) return .{
.grade = .A,
.explanation = "Strong - MIME sniffing is off; the declared Content-Type is final.",
.recommendation = "Best practice met - nothing to change.",
};
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present but the value ({s}) is not nosniff, so browsers ignore the header.", .{v}),
.recommendation = "Send exactly: x-content-type-options: nosniff",
};
}
const REFERRER_STRICT = [_][]const u8{ "no-referrer", "same-origin", "strict-origin", "strict-origin-when-cross-origin" };
const REFERRER_WEAK = [_][]const u8{ "origin", "origin-when-cross-origin", "no-referrer-when-downgrade" };
fn inList(list: []const []const u8, v: []const u8) bool {
for (list) |entry| {
if (eqlIgnoreCase(entry, v)) return true;
}
return false;
}
fn gradeReferrerPolicy(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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)",
};
const t = trim(v);
if (eqlIgnoreCase(t, "unsafe-url")) return .{
.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.",
};
if (inList(&REFERRER_STRICT, t)) return .{
.grade = .A,
.explanation = try std.fmt.allocPrint(allocator, "Strong - {s} leaks at most the origin cross-origin.", .{t}),
.recommendation = "Best practice met - nothing to change.",
};
if (inList(&REFERRER_WEAK, t)) return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present but leaky - {s} still shares the full URL in some same- or cross-origin cases.", .{t}),
.recommendation = "Tighten to strict-origin-when-cross-origin, strict-origin, same-origin, or no-referrer.",
};
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Unrecognized value ({s}) - browsers fall back to their default policy.", .{v}),
.recommendation = "Use one of the standard directives, e.g. strict-origin-when-cross-origin.",
};
}
fn gradePermissionsPolicy(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.grade = .C,
.explanation = "Missing - powerful features (camera, geolocation, payment...) default to the browser policy, not yours.",
.recommendation = "Add: permissions-policy: camera=(), microphone=(), geolocation=()",
};
// Strip all whitespace, lowercase.
var buf: [1024]u8 = undefined;
var n: usize = 0;
for (v) |c| {
if (c == ' ' or c == '\t') continue;
if (n == buf.len) break;
buf[n] = std.ascii.toLower(c);
n += 1;
}
const compact = buf[0..n];
if (compact.len == 0) return .{
.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=().",
};
if (eqlIgnoreCase(compact, "*") or indexOfIgnoreCase(compact, "=*") != null) return .{
.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 *.",
};
return .{
.grade = .A,
.explanation = "Strong - the policy restricts at least one powerful feature.",
.recommendation = "Best practice met - nothing to change.",
};
}
fn gradeXssProtection(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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.",
};
const t = trim(v);
if (eqlIgnoreCase(t, "0") or startsWithIgnoreCase(t, "0;")) return .{
.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.",
};
if (t.len > 0 and t[0] == '1') return .{
.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.",
};
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Present with an unrecognized value ({s}) - remove the deprecated header.", .{v}),
.recommendation = "Send '0' or drop the header entirely.",
};
}
fn gradeCoop(allocator: std.mem.Allocator, value: ?[]const u8) !Grading {
const v = value orelse return .{
.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",
};
const t = trim(v);
if (eqlIgnoreCase(t, "same-origin")) return .{
.grade = .A,
.explanation = "Strong - the window handle is isolated from cross-origin openers.",
.recommendation = "Best practice met - nothing to change.",
};
if (eqlIgnoreCase(t, "same-origin-allow-popups")) return .{
.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.",
};
if (eqlIgnoreCase(t, "unsafe-none")) return .{
.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.",
};
return .{
.grade = .B,
.explanation = try std.fmt.allocPrint(allocator, "Unrecognized value ({s}) - browsers treat it as no COOP.", .{v}),
.recommendation = "Use same-origin.",
};
}
/// Compile-time-complete grader dispatch: every catalog name has a grader.
fn gradeHeader(allocator: std.mem.Allocator, name: []const u8, value: ?[]const u8) !Grading {
if (eqlIgnoreCase(name, "Strict-Transport-Security")) return gradeHsts(allocator, value);
if (eqlIgnoreCase(name, "Content-Security-Policy")) return gradeCsp(allocator, value);
if (eqlIgnoreCase(name, "X-Frame-Options")) return gradeFrameOptions(allocator, value);
if (eqlIgnoreCase(name, "X-Content-Type-Options")) return gradeContentTypeOptions(allocator, value);
if (eqlIgnoreCase(name, "Referrer-Policy")) return gradeReferrerPolicy(allocator, value);
if (eqlIgnoreCase(name, "Permissions-Policy")) return gradePermissionsPolicy(allocator, value);
if (eqlIgnoreCase(name, "X-XSS-Protection")) return gradeXssProtection(allocator, value);
if (eqlIgnoreCase(name, "Cross-Origin-Opener-Policy")) return gradeCoop(allocator, value);
unreachable; // every catalog name is handled above
}
/// Map a weighted 0-100 score to the overall A/B/C/F grade.
fn overallGrade(score: i64) HeaderGrade {
if (score >= 90) return .A;
if (score >= 70) return .B;
if (score >= 45) return .C;
return .F;
}
/// 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. Caller owns the returned slices.
pub fn analyzeHeaders(allocator: std.mem.Allocator, raw: []const u8) !HeaderAnalysis {
var parsed = try parseHeaders(allocator, raw);
defer parsed.deinit();
var headers = try allocator.alloc(ParsedHeader, SECURITY_HEADERS.len);
errdefer allocator.free(headers);
var total_weight: f64 = 0;
var weighted: f64 = 0;
for (SECURITY_HEADERS, 0..) |spec, i| {
var lower_buf: [64]u8 = undefined;
const key = lowerInto(&lower_buf, spec.name);
const value = parsed.get(key);
const grading = try gradeHeader(allocator, spec.name, value);
headers[i] = .{
.name = spec.name,
.value = if (value) |val| try allocator.dupe(u8, val) else null,
.grade = grading.grade,
.explanation = grading.explanation,
.recommendation = grading.recommendation,
};
total_weight += spec.weight;
weighted += gradePoints(grading.grade) * spec.weight;
}
const score_f = weighted / total_weight;
const score: i64 = @intFromFloat(@round(score_f));
var recommendations = std.ArrayList([]const u8).init(allocator);
errdefer recommendations.deinit();
for (headers) |h| {
if (h.grade != .A) try recommendations.append(h.recommendation);
}
return .{
.headers = headers,
.grade = overallGrade(score),
.score = score,
.recommendations = try recommendations.toOwnedSlice(),
};
}
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 →