CSP Builder — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! csp-builder — Content-Security-Policy build / parse / lint / score.
//!
//! Language: Zig 0.14 (standard library only)
//! Ported from: src/lib/csp-builder.ts (the canonical TypeScript implementation).
//! display source — part of CosmoDev's polyglot tool pages.
//!
//! Pure logic - deterministic, no DOM. A CSP is modeled as an ordered map of
//! directive -> source list (Zig's StringArrayHashMap preserves insertion
//! order, which the TS Record relies on for unknown-directive ordering).
//! 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 fails - parse is lenient by
//! design so a pasted real-world header always yields something editable.
const std = @import("std");
/// How a directive takes its value: a source list, a single URL, or a bare flag.
pub const DirectiveKind = enum { sources, url, flag };
/// How much exposure the directive controls (drives UI emphasis).
pub const DirectiveRisk = enum { low, medium, high };
/// One entry of the built-in directive catalog.
pub const DirectiveInfo = struct {
name: []const u8,
kind: DirectiveKind,
description: []const u8,
risk: DirectiveRisk,
/// Sources inserted when the directive is enabled in the UI.
default_sources: []const []const u8,
};
/// A policy: directive name (lowercase) -> enabled source list.
/// Present key = enabled. Insertion order is preserved.
pub const CSPDirectiveMap = std.StringArrayHashMap([]const []const u8);
/// The catalog, in canonical build/display order.
pub const csp_directives = [_]DirectiveInfo{
.{
.name = "default-src",
.kind = .sources,
.description = "Fallback for every fetch directive you do not set explicitly. Set this first, then tighten individual directives.",
.risk = .medium,
.default_sources = &.{"'self'"},
},
.{
.name = "script-src",
.kind = .sources,
.description = "Where scripts may load from. The single most important XSS control - keep it as tight as you can.",
.risk = .high,
.default_sources = &.{"'self'"},
},
.{
.name = "style-src",
.kind = .sources,
.description = "Where stylesheets may load from. Also gates inline style attributes.",
.risk = .medium,
.default_sources = &.{"'self'"},
},
.{
.name = "img-src",
.kind = .sources,
.description = "Where images and favicons may load from.",
.risk = .low,
.default_sources = &.{"'self'"},
},
.{
.name = "connect-src",
.kind = .sources,
.description = "Which URLs scripts may connect to (fetch, XHR, WebSocket). Your data-exfiltration boundary.",
.risk = .medium,
.default_sources = &.{"'self'"},
},
.{
.name = "font-src",
.kind = .sources,
.description = "Where web fonts may load from.",
.risk = .low,
.default_sources = &.{"'self'"},
},
.{
.name = "frame-src",
.kind = .sources,
.description = "Which URLs may be embedded as child browsing contexts (iframe, frame).",
.risk = .low,
.default_sources = &.{"'self'"},
},
.{
.name = "media-src",
.kind = .sources,
.description = "Where audio and video may load from.",
.risk = .low,
.default_sources = &.{"'self'"},
},
.{
.name = "object-src",
.kind = .sources,
.description = "Where plugin content (object, embed, applet) may load from. Almost always should be 'none'.",
.risk = .high,
.default_sources = &.{"'none'"},
},
.{
.name = "base-uri",
.kind = .sources,
.description = "Which URLs may set the document base. Restrict to 'self' to block <base> hijacking of relative URLs.",
.risk = .high,
.default_sources = &.{"'self'"},
},
.{
.name = "form-action",
.kind = .sources,
.description = "Where forms may submit to. Does not fall back to default-src.",
.risk = .medium,
.default_sources = &.{"'self'"},
},
.{
.name = "frame-ancestors",
.kind = .sources,
.description = "Which parents may embed this page (clickjacking control). Ignored inside a <meta> tag - header delivery only.",
.risk = .medium,
.default_sources = &.{"'self'"},
},
.{
.name = "report-uri",
.kind = .url,
.description = "URL where the browser posts violation reports. Pair with a report collector.",
.risk = .low,
.default_sources = &.{},
},
.{
.name = "upgrade-insecure-requests",
.kind = .flag,
.description = "Tells the browser to rewrite http:// subresource requests to https://.",
.risk = .low,
.default_sources = &.{},
},
.{
.name = "block-all-mixed-content",
.kind = .flag,
.description = "Blocks loading of any http:// subresource on an https:// page.",
.risk = .low,
.default_sources = &.{},
},
};
/// Source presets offered in the UI when adding a source to a directive.
pub const common_sources = [_][]const u8{
"'self'", "'none'", "'unsafe-inline'", "'unsafe-eval'",
"'strict-dynamic'", "data:", "blob:", "https:",
};
fn isFlagDirective(name: []const u8) bool {
for (csp_directives) |d| {
if (d.kind == .flag and std.mem.eql(u8, d.name, name)) return true;
}
return false;
}
fn isKnownDirective(name: []const u8) bool {
for (csp_directives) |d| {
if (std.mem.eql(u8, d.name, name)) return true;
}
return false;
}
fn eqlLower(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
/// 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. Caller owns the result.
pub fn buildCSP(allocator: std.mem.Allocator, directives: *const CSPDirectiveMap) ![]u8 {
var parts = std.ArrayList([]const u8).init(allocator);
defer parts.deinit();
const emit = struct {
fn f(list: *std.ArrayList([]const u8), map: *const CSPDirectiveMap, name: []const u8) !void {
const sources = map.get(name) orelse return;
if (isFlagDirective(name)) {
try list.append(name);
return;
}
if (sources.len == 0) return;
const joined = try std.mem.join(map.allocator, " ", sources);
defer map.allocator.free(joined);
try list.append(try std.fmt.allocPrint(list.allocator, "{s} {s}", .{ name, joined }));
}
}.f;
for (csp_directives) |d| try emit(&parts, directives, d.name);
var it = directives.iterator();
while (it.next()) |entry| {
if (!isKnownDirective(entry.key_ptr.*)) try emit(&parts, directives, entry.key_ptr.*);
}
return std.mem.join(allocator, "; ", parts.items);
}
/// 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 fails on garbage -
/// garbage in, empty map out. Caller owns the returned map.
pub fn parseCSP(allocator: std.mem.Allocator, header: []const u8) !CSPDirectiveMap {
var out = CSPDirectiveMap.init(allocator);
errdefer out.deinit();
var text = std.mem.trim(u8, header, " \t\r\n");
if (std.ascii.startsWithIgnoreCase(text, "content-security-policy")) {
var rest = text["content-security-policy".len..];
rest = std.mem.trimLeft(u8, rest, " \t");
if (rest.len > 0 and rest[0] == ':') {
text = rest[1..];
}
}
var tokens = std.mem.splitScalar(u8, text, ';');
while (tokens.next()) |token| {
var words = std.ArrayList([]const u8).init(allocator);
defer words.deinit();
var ws = std.mem.tokenizeAny(u8, token, " \t\r\n");
while (ws.next()) |w| try words.append(w);
if (words.items.len == 0) continue;
const name = try std.ascii.allocLowerString(allocator, words.items[0]);
if (out.contains(name)) {
allocator.free(name);
continue;
}
const sources = try allocator.dupe([]const u8, words.items[1..]);
try out.put(name, sources);
}
return out;
}
/// 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.
pub fn isRiskySource(source: []const u8) bool {
const risky = [_][]const u8{ "'unsafe-inline'", "'unsafe-eval'", "data:", "http:", "*" };
const s = std.mem.trim(u8, source, " \t\r\n");
for (risky) |r| {
if (eqlLower(s, r)) return true;
}
return std.ascii.startsWithIgnoreCase(s, "http://");
}
/// Short human explanation for each risky source (tooltip text in the UI).
pub fn riskExplanation(source: []const u8) []const u8 {
const s = std.mem.trim(u8, source, " \t\r\n");
if (eqlLower(s, "'unsafe-inline'"))
return "Allows inline <script>/<style> and event handlers - defeats most of CSP's XSS protection.";
if (eqlLower(s, "'unsafe-eval'"))
return "Allows eval() and similar code execution - weakens XSS protection.";
if (std.mem.eql(u8, s, "*"))
return "Allows every origin - effectively no restriction for this directive.";
if (eqlLower(s, "data:"))
return "data: URIs can carry arbitrary payloads and are same-origin - attackers can smuggle content through them.";
if (eqlLower(s, "http:"))
return "Allows insecure origins - a network attacker can inject or tamper with subresources.";
return "Insecure http:// URL - traffic can be tampered with in transit.";
}
/// One policy problem: either policy-wide (directive == "") or a risky source.
pub const CspIssue = struct {
/// Directive the issue belongs to; "" for policy-wide issues.
directive: []const u8,
/// The offending source, or null for policy-wide issues.
source: ?[]const u8,
message: []const u8,
};
/// 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. Caller owns the returned list and each issue's message.
pub fn validateCSP(
allocator: std.mem.Allocator,
directives: *const CSPDirectiveMap,
) !std.ArrayList(CspIssue) {
var issues = std.ArrayList(CspIssue).init(allocator);
errdefer issues.deinit();
if (directives.get("default-src") == null) {
try issues.append(.{
.directive = "",
.source = null,
.message = "No default-src - every directive you don't set explicitly falls back to the browser's permissive default.",
});
}
var it = directives.iterator();
while (it.next()) |entry| {
for (entry.value_ptr.*) |src| {
if (isRiskySource(src)) {
try issues.append(.{
.directive = entry.key_ptr.*,
.source = src,
.message = try std.fmt.allocPrint(
allocator,
"{s}: {s} weakens this policy - {s}",
.{ entry.key_ptr.*, src, riskExplanation(src) },
),
});
}
}
}
return issues;
}
/// Score penalty per risky source (case-insensitive key).
fn scorePenalty(source_lower: []const u8) i32 {
if (std.mem.eql(u8, source_lower, "'unsafe-inline'")) return 20;
if (std.mem.eql(u8, source_lower, "'unsafe-eval'")) return 15;
if (std.mem.eql(u8, source_lower, "*")) return 20;
if (std.mem.eql(u8, source_lower, "data:")) return 10;
if (std.mem.eql(u8, source_lower, "http:")) return 10;
if (std.mem.startsWith(u8, source_lower, "http://")) return 10;
return 0;
}
/// 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.
pub fn securityScore(directives: *const CSPDirectiveMap) i32 {
var score: i32 = 100;
if (directives.get("default-src") == null) score -= 10;
var it = directives.iterator();
while (it.next()) |entry| {
for (entry.value_ptr.*) |src| {
const s = std.mem.trim(u8, src, " \t\r\n");
var buf: [512]u8 = undefined;
const lower = std.ascii.lowerString(buf[0..@min(s.len, buf.len)], s);
score -= scorePenalty(lower);
}
}
return @max(0, @min(100, score));
}
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 →